woltapi 0.2.0__tar.gz → 0.4.0__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 (35) hide show
  1. {woltapi-0.2.0/src/woltapi.egg-info → woltapi-0.4.0}/PKG-INFO +118 -18
  2. {woltapi-0.2.0 → woltapi-0.4.0}/README.md +117 -17
  3. {woltapi-0.2.0 → woltapi-0.4.0}/examples/order.py +50 -127
  4. {woltapi-0.2.0 → woltapi-0.4.0}/pyproject.toml +1 -1
  5. {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi/__init__.py +2 -0
  6. woltapi-0.4.0/src/woltapi/basket.py +409 -0
  7. {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi/client.py +82 -1
  8. {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi/selection.py +55 -14
  9. {woltapi-0.2.0 → woltapi-0.4.0/src/woltapi.egg-info}/PKG-INFO +118 -18
  10. {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi.egg-info/SOURCES.txt +2 -0
  11. woltapi-0.4.0/tests/test_basket.py +316 -0
  12. {woltapi-0.2.0 → woltapi-0.4.0}/tests/test_client.py +262 -0
  13. {woltapi-0.2.0 → woltapi-0.4.0}/tests/test_order_example.py +69 -111
  14. {woltapi-0.2.0 → woltapi-0.4.0}/tests/test_selection.py +173 -0
  15. {woltapi-0.2.0 → woltapi-0.4.0}/LICENSE +0 -0
  16. {woltapi-0.2.0 → woltapi-0.4.0}/MANIFEST.in +0 -0
  17. {woltapi-0.2.0 → woltapi-0.4.0}/RELEASING.md +0 -0
  18. {woltapi-0.2.0 → woltapi-0.4.0}/examples/browse.py +0 -0
  19. {woltapi-0.2.0 → woltapi-0.4.0}/examples/check_session.py +0 -0
  20. {woltapi-0.2.0 → woltapi-0.4.0}/setup.cfg +0 -0
  21. {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi/auth.py +0 -0
  22. {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi/credentials.py +0 -0
  23. {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi/errors.py +0 -0
  24. {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi/models.py +0 -0
  25. {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi/purchase.py +0 -0
  26. {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi/services.py +0 -0
  27. {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi/transport.py +0 -0
  28. {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi.egg-info/dependency_links.txt +0 -0
  29. {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi.egg-info/requires.txt +0 -0
  30. {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi.egg-info/top_level.txt +0 -0
  31. {woltapi-0.2.0 → woltapi-0.4.0}/tests/__init__.py +0 -0
  32. {woltapi-0.2.0 → woltapi-0.4.0}/tests/test_auth.py +0 -0
  33. {woltapi-0.2.0 → woltapi-0.4.0}/tests/test_browse.py +0 -0
  34. {woltapi-0.2.0 → woltapi-0.4.0}/tests/test_check_session.py +0 -0
  35. {woltapi-0.2.0 → woltapi-0.4.0}/tests/test_purchase.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: woltapi
3
- Version: 0.2.0
3
+ Version: 0.4.0
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
 
@@ -261,6 +262,106 @@ Responses can contain personal information. Avoid printing entire responses or
261
262
  sending them to shared logs. The examples print saved addresses and card labels
262
263
  to the local terminal only. The browsing example prints only selected fields.
263
264
 
265
+ ### Baskets
266
+
267
+ A basket is your list of chosen items at one restaurant. The `Basket` class
268
+ builds that list locally, with no network requests. It reads names, option
269
+ prices, and totals from the restaurant's menu, so you do not type any prices:
270
+
271
+ ```python
272
+ from woltapi import Basket, OptionSelection, OptionValueSelection
273
+
274
+ menu = client.get_assortment(restaurant.slug)
275
+ basket = Basket(menu, "en")
276
+
277
+ basket.add_item("<item id>", count=2)
278
+ basket.add_item(
279
+ "<other item id>",
280
+ options=[
281
+ OptionSelection(
282
+ "<item option configuration id>", [OptionValueSelection("<value id>", 1)]
283
+ ),
284
+ ],
285
+ )
286
+ basket.set_count("<item id>", 1)
287
+ basket.set_options("<item id>", [])
288
+ basket.remove_item("<other item id>")
289
+
290
+ items = basket.item_selections()
291
+ ```
292
+
293
+ Item, option, and value IDs come from the assortment dictionary. For
294
+ `OptionSelection`, use the configuration ID from the catalog item's own
295
+ `options` list, not the root option ID from the assortment's top-level
296
+ `options`. Pass the result of `item_selections()` to
297
+ `client.create_selection(...)`, which validates the whole selection against the
298
+ current menu.
299
+
300
+ Wolt also stores one basket per restaurant on its servers. These are the
301
+ baskets you see in the Wolt app. The library can read and replace them:
302
+
303
+ | Call | What it does |
304
+ | --- | --- |
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`. |
307
+ | `client.get_basket_count()` | The number of baskets stored on the server. |
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. |
310
+
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.
364
+
264
365
  ## Can it order food?
265
366
 
266
367
  Not as a simple, ready-to-use feature yet. There is no `order("pizza")` method.
@@ -274,23 +375,22 @@ python examples/order.py --latitude 60.17 --longitude 24.94 --query pizza \
274
375
  ```
275
376
 
276
377
  Use your own coordinates. It reads and saves your refresh token as described in
277
- [Get a token](#get-a-token), guides you through selecting an item and a saved
278
- delivery target/card, then asks before requesting a price. It never submits a
279
- purchase. Basket saving is off by default and needs a separate confirmation if
280
- enabled.
281
-
282
- The checkout example derives `category_id`, `category_ids`, and the three
283
- checkout exclusion flags from the assortment, so those fields do not need a
284
- browser context file. It only supports items in exactly one category and stops
285
- rather than guessing for zero or multiple categories. If it stops, you can
286
- supply the missing values yourself with `--context-file <path>`, a JSON object
287
- whose `checkout_fields` entries were copied from your own browser's checkout
288
- request for the same item. It may still stop if other required catalog data is
289
- missing. Run `python examples/order.py --help` for available options.
290
-
291
- The library has methods to choose items, save a basket, ask Wolt for a price,
292
- and submit a purchase. But some required inputs still need to come from your
293
- own code, including browser/device information and detailed item data.
378
+ [Get a token](#get-a-token), guides you through selecting one or more items with
379
+ their options and a saved delivery target/card, then asks before requesting a
380
+ price. Line totals come from the menu through the local basket builder; you do
381
+ not type any amounts. It never submits a purchase. Basket saving is off by
382
+ default and needs a separate confirmation if enabled.
383
+
384
+ The basket builder derives `category_id`, `category_ids`, and the three checkout
385
+ exclusion flags from the assortment. It only supports items in exactly one
386
+ category and stops rather than guessing for zero or multiple categories. It may
387
+ also stop if required catalog data is missing. Run
388
+ `python examples/order.py --help` for available options.
389
+
390
+ The library has methods to build and manage a basket, save it, ask Wolt for a
391
+ price, and submit a purchase. But some purchase inputs still need to come from
392
+ your own code, including browser/device information and a few purchase-only
393
+ item fields.
294
394
 
295
395
  Order-history and saved-address reads have worked in live checks. **Payment and
296
396
  purchase handling have not been verified with a real order.** The purchase code
@@ -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
 
@@ -243,6 +244,106 @@ Responses can contain personal information. Avoid printing entire responses or
243
244
  sending them to shared logs. The examples print saved addresses and card labels
244
245
  to the local terminal only. The browsing example prints only selected fields.
245
246
 
247
+ ### Baskets
248
+
249
+ A basket is your list of chosen items at one restaurant. The `Basket` class
250
+ builds that list locally, with no network requests. It reads names, option
251
+ prices, and totals from the restaurant's menu, so you do not type any prices:
252
+
253
+ ```python
254
+ from woltapi import Basket, OptionSelection, OptionValueSelection
255
+
256
+ menu = client.get_assortment(restaurant.slug)
257
+ basket = Basket(menu, "en")
258
+
259
+ basket.add_item("<item id>", count=2)
260
+ basket.add_item(
261
+ "<other item id>",
262
+ options=[
263
+ OptionSelection(
264
+ "<item option configuration id>", [OptionValueSelection("<value id>", 1)]
265
+ ),
266
+ ],
267
+ )
268
+ basket.set_count("<item id>", 1)
269
+ basket.set_options("<item id>", [])
270
+ basket.remove_item("<other item id>")
271
+
272
+ items = basket.item_selections()
273
+ ```
274
+
275
+ Item, option, and value IDs come from the assortment dictionary. For
276
+ `OptionSelection`, use the configuration ID from the catalog item's own
277
+ `options` list, not the root option ID from the assortment's top-level
278
+ `options`. Pass the result of `item_selections()` to
279
+ `client.create_selection(...)`, which validates the whole selection against the
280
+ current menu.
281
+
282
+ Wolt also stores one basket per restaurant on its servers. These are the
283
+ baskets you see in the Wolt app. The library can read and replace them:
284
+
285
+ | Call | What it does |
286
+ | --- | --- |
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`. |
289
+ | `client.get_basket_count()` | The number of baskets stored on the server. |
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. |
292
+
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.
346
+
246
347
  ## Can it order food?
247
348
 
248
349
  Not as a simple, ready-to-use feature yet. There is no `order("pizza")` method.
@@ -256,23 +357,22 @@ python examples/order.py --latitude 60.17 --longitude 24.94 --query pizza \
256
357
  ```
257
358
 
258
359
  Use your own coordinates. It reads and saves your refresh token as described in
259
- [Get a token](#get-a-token), guides you through selecting an item and a saved
260
- delivery target/card, then asks before requesting a price. It never submits a
261
- purchase. Basket saving is off by default and needs a separate confirmation if
262
- enabled.
263
-
264
- The checkout example derives `category_id`, `category_ids`, and the three
265
- checkout exclusion flags from the assortment, so those fields do not need a
266
- browser context file. It only supports items in exactly one category and stops
267
- rather than guessing for zero or multiple categories. If it stops, you can
268
- supply the missing values yourself with `--context-file <path>`, a JSON object
269
- whose `checkout_fields` entries were copied from your own browser's checkout
270
- request for the same item. It may still stop if other required catalog data is
271
- missing. Run `python examples/order.py --help` for available options.
272
-
273
- The library has methods to choose items, save a basket, ask Wolt for a price,
274
- and submit a purchase. But some required inputs still need to come from your
275
- own code, including browser/device information and detailed item data.
360
+ [Get a token](#get-a-token), guides you through selecting one or more items with
361
+ their options and a saved delivery target/card, then asks before requesting a
362
+ price. Line totals come from the menu through the local basket builder; you do
363
+ not type any amounts. It never submits a purchase. Basket saving is off by
364
+ default and needs a separate confirmation if enabled.
365
+
366
+ The basket builder derives `category_id`, `category_ids`, and the three checkout
367
+ exclusion flags from the assortment. It only supports items in exactly one
368
+ category and stops rather than guessing for zero or multiple categories. It may
369
+ also stop if required catalog data is missing. Run
370
+ `python examples/order.py --help` for available options.
371
+
372
+ The library has methods to build and manage a basket, save it, ask Wolt for a
373
+ price, and submit a purchase. But some purchase inputs still need to come from
374
+ your own code, including browser/device information and a few purchase-only
375
+ item fields.
276
376
 
277
377
  Order-history and saved-address reads have worked in live checks. **Payment and
278
378
  purchase handling have not been verified with a real order.** The purchase code
@@ -2,22 +2,19 @@
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
 
9
8
  from browse import TokenFileError, check_token_file, refresh_credentials, text
10
9
 
11
10
  from woltapi import (
11
+ Basket,
12
12
  DeliverySelection,
13
13
  HTTPStatusError,
14
- ItemSelection,
15
14
  OptionSelection,
16
15
  OptionValueSelection,
17
- VenueCheckoutContext,
18
16
  WoltApiError,
19
17
  WoltClient,
20
- derive_checkout_fields,
21
18
  )
22
19
 
23
20
 
@@ -75,25 +72,6 @@ def card_description(card):
75
72
  )
76
73
 
77
74
 
78
- def fields(source, supplied, names, section):
79
- if not isinstance(source, dict) or not isinstance(supplied, dict):
80
- raise CheckoutInputError(f"Expected an object for {section}.")
81
- missing = [name for name in names if name not in source and name not in supplied]
82
- if missing:
83
- raise CheckoutInputError(
84
- f"Missing {section}: {', '.join(missing)}. "
85
- "Supply current browser values using --context-file; no defaults were invented."
86
- )
87
- if any(
88
- name in source and name in supplied and source[name] != supplied[name]
89
- for name in names
90
- ):
91
- raise CheckoutInputError(
92
- f"The {section} context conflicts with current server data."
93
- )
94
- return {name: source[name] if name in source else supplied[name] for name in names}
95
-
96
-
97
75
  def select_options(item, assortment, language):
98
76
  configurations = item.get("options")
99
77
  roots = assortment.get("options")
@@ -101,7 +79,6 @@ def select_options(item, assortment, language):
101
79
  raise CheckoutInputError("Missing catalog option lists.")
102
80
  selected = []
103
81
  descriptions = []
104
- option_amount = 0
105
82
  for config in configurations:
106
83
  if not isinstance(config, dict) or config.get("prerequisite_values") != []:
107
84
  raise CheckoutInputError(
@@ -145,100 +122,60 @@ def select_options(item, assortment, language):
145
122
  f"Count for {name} (0 to {single}): ", 0, single, empty_value=0
146
123
  )
147
124
  if count:
148
- price = value.get("price")
149
- if type(price) is not int:
150
- raise CheckoutInputError(
151
- "Missing or invalid catalog option value price."
152
- )
153
125
  selections.append(OptionValueSelection(value["id"], count))
154
126
  descriptions.append(f"{count} x {name}")
155
- option_amount += price * count
156
127
  if not low <= sum(v.count for v in selections) <= high:
157
128
  raise CheckoutInputError("Selected options do not meet the catalog limits.")
158
129
  if selections:
159
130
  selected.append(OptionSelection(config["id"], selections))
160
- return selected, descriptions, option_amount
131
+ return selected, descriptions
161
132
 
162
133
 
163
- def checkout(client, args, context):
134
+ def checkout(client, args):
164
135
  found = client.search_venues(args.query, args.latitude, args.longitude)
165
136
  venue = choose("restaurant", found, lambda v: text(v.title or v.slug))
166
- static = client.get_venue_static(venue.slug).get("venue")
167
- 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:
168
139
  raise CheckoutInputError(
169
140
  "Static venue response does not match the selected restaurant."
170
141
  )
171
- venue_fields = fields(
172
- static,
173
- context.get("venue", {}),
174
- ("country", "currency", "self_delivery"),
175
- "venue",
176
- )
177
- 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:
178
143
  raise CheckoutInputError(
179
144
  "Venue currency changed; start again with current data."
180
145
  )
181
- currency = venue_fields["currency"]
146
+ currency = venue_context.currency
182
147
  assortment = client.get_assortment(venue.slug)
183
148
  catalog_items = assortment.get("items")
184
149
  if not isinstance(catalog_items, list) or not all(
185
150
  isinstance(i, dict) for i in catalog_items
186
151
  ):
187
152
  raise CheckoutInputError("Missing assortment items.")
188
- item = choose(
189
- "menu item",
190
- catalog_items,
191
- lambda i: f"{text(i.get('name'), args.language)} | {money(i.get('price'), currency)}",
192
- )
193
- if context and (
194
- context.get("venue_id") != venue.id or context.get("item_id") != item.get("id")
195
- ):
196
- raise CheckoutInputError(
197
- "Context venue_id and item_id must match the selected restaurant and item."
153
+ basket = Basket(assortment, args.language)
154
+ selected_items = []
155
+ while True:
156
+ item = choose(
157
+ "menu item",
158
+ catalog_items,
159
+ lambda i: f"{text(i.get('name'), args.language)} | {money(i.get('price'), currency)}",
198
160
  )
199
- if item.get("restrictions") != []:
200
- raise CheckoutInputError("Restricted items are unsupported.")
201
- methods = item.get("allowed_delivery_methods")
202
- if not isinstance(methods, list) or "homedelivery" not in methods:
203
- raise CheckoutInputError("This item does not explicitly support home delivery.")
204
- item_price = item.get("price")
205
- if type(item_price) is not int:
206
- raise CheckoutInputError("Missing or invalid catalog item price.")
207
- count = number("Item quantity: ", 1)
208
- options, option_names, option_amount = select_options(
209
- item, assortment, args.language
210
- )
211
- checkout_fields = fields(
212
- derive_checkout_fields(assortment, item),
213
- context.get("checkout_fields", {}),
214
- (
215
- "category_id",
216
- "category_ids",
217
- "exclude_from_credits",
218
- "exclude_from_discounts",
219
- "exclude_from_discounts_min_basket",
220
- "alcohol_permille",
221
- "restrictions",
222
- ),
223
- "checkout_fields",
224
- )
225
- if checkout_fields["alcohol_permille"] != 0:
226
- raise CheckoutInputError("Age-restricted items are unsupported.")
227
- payment_fields = fields(
228
- item,
229
- context.get("payment_fields", {}),
230
- (
231
- "product_hierarchy_tags",
232
- "vat_percentage",
233
- "vat_percentage_decimal",
234
- ),
235
- "payment_fields",
236
- )
237
- unit_amount = item_price + option_amount
238
- print(f"Computed configured unit price: {money(unit_amount, currency)}")
239
- if input("Does this match the Wolt UI? Type YES: ").strip() != "YES":
240
- print("Cancelled before card lookup or quote.")
241
- return
161
+ if item.get("restrictions") != []:
162
+ raise CheckoutInputError("Restricted items are unsupported.")
163
+ methods = item.get("allowed_delivery_methods")
164
+ if not isinstance(methods, list) or "homedelivery" not in methods:
165
+ raise CheckoutInputError(
166
+ "This item does not explicitly support home delivery."
167
+ )
168
+ count = number("Item quantity: ", 1)
169
+ options, option_names = select_options(item, assortment, args.language)
170
+ basket.add_item(item["id"], count, options)
171
+ selected_item = basket.contents[item["id"]]
172
+ if selected_item.checkout_fields["alcohol_permille"] != 0:
173
+ raise CheckoutInputError("Age-restricted items are unsupported.")
174
+ selected_items.append((selected_item, option_names))
175
+ if input("Add another menu item? Type YES: ").strip() != "YES":
176
+ break
177
+
178
+ item_selections = basket.item_selections()
242
179
  tip = number("Courier tip in cents (0 for none): ")
243
180
  delivery = choose(
244
181
  "saved delivery target", client.list_delivery_targets(), delivery_description
@@ -252,15 +189,16 @@ def checkout(client, args, context):
252
189
  return
253
190
  card_context = {
254
191
  "venue_id": venue.id,
255
- "country": venue_fields["country"],
192
+ "country": venue_context.country,
256
193
  "delivery_method": "homedelivery",
257
194
  "available_methods": ["card"],
258
195
  "items": [
259
196
  {
260
- "id": item["id"],
261
- "alcohol_permille": checkout_fields["alcohol_permille"],
262
- **payment_fields,
197
+ "id": item.id,
198
+ "alcohol_permille": item.checkout_fields["alcohol_permille"],
199
+ **item.payment_fields,
263
200
  }
201
+ for item in item_selections
264
202
  ],
265
203
  }
266
204
  card = choose(
@@ -268,31 +206,24 @@ def checkout(client, args, context):
268
206
  client.get_payment_methods(card_context),
269
207
  card_description,
270
208
  )
271
- selected_item = ItemSelection(
272
- id=item["id"],
273
- count=count,
274
- basket_name=text(item.get("name"), args.language),
275
- basket_price=unit_amount,
276
- end_amount=unit_amount,
277
- substitution_allowed=False,
278
- checkout_fields=checkout_fields,
279
- options=options,
280
- payment_fields=payment_fields,
281
- )
282
209
  selection = client.create_selection(
283
210
  assortment,
284
- venue=VenueCheckoutContext(id=venue.id, preorder_config=None, **venue_fields),
211
+ venue=venue_context,
285
212
  delivery=DeliverySelection(delivery.id, args.latitude, args.longitude),
286
213
  payment_method={"id": card.id, "type": card.type},
287
214
  courier_tip=tip,
288
- items=[selected_item],
289
- )
290
- print(
291
- f"\n{count} x {selected_item.basket_name} at {text(venue.title or venue.slug)}"
215
+ items=item_selections,
292
216
  )
293
- for name in option_names:
294
- print(f" Option: {name}")
295
- print(f"Configured unit price: {money(unit_amount, currency)}")
217
+ for selected_item, option_names in selected_items:
218
+ print(
219
+ f"\n{selected_item.count} x {selected_item.basket_name} "
220
+ f"at {text(venue.title or venue.slug)}"
221
+ )
222
+ for name in option_names:
223
+ print(f" Option: {name}")
224
+ print(
225
+ f"Catalog-derived line total: {money(selected_item.basket_price, currency)}"
226
+ )
296
227
  print(
297
228
  f"Delivery: {delivery_description(delivery)} | Card: {card_description(card)}"
298
229
  )
@@ -357,11 +288,6 @@ def main():
357
288
  parser.add_argument("--token-file", type=Path, required=True)
358
289
  parser.add_argument("--query", default="pizza")
359
290
  parser.add_argument("--language", default="en")
360
- parser.add_argument(
361
- "--context-file",
362
- type=Path,
363
- help="Optional current browser metadata for fields missing from the catalog",
364
- )
365
291
  parser.add_argument(
366
292
  "--save-basket",
367
293
  action="store_true",
@@ -377,11 +303,8 @@ def main():
377
303
  parser.error("Provide valid latitude and longitude.")
378
304
  check_token_file(parser, args.token_file)
379
305
  try:
380
- context = json.loads(args.context_file.read_text()) if args.context_file else {}
381
- if not isinstance(context, dict):
382
- raise CheckoutInputError("The context file must contain a JSON object.")
383
306
  client = WoltClient(refresh_credentials(args.language, args.token_file))
384
- checkout(client, args, context)
307
+ checkout(client, args)
385
308
  except CheckoutInputError as exc:
386
309
  print(f"Checkout stopped: {exc}")
387
310
  return 1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "woltapi"
7
- version = "0.2.0"
7
+ version = "0.4.0"
8
8
  description = "A small synchronous client for observed Wolt ordering flows."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,7 @@
1
1
  """Synchronous selection and explicit purchase client for observed Wolt hosts."""
2
2
 
3
3
  from .auth import RefreshTokenCredentials
4
+ from .basket import Basket
4
5
  from .client import WoltClient
5
6
  from .credentials import SessionCredentials
6
7
  from .errors import (
@@ -50,6 +51,7 @@ from .transport import WoltTransport
50
51
  __all__ = [
51
52
  "DeliveryTarget",
52
53
  "DeliverySelection",
54
+ "Basket",
53
55
  "DuplicatePurchaseAttempt",
54
56
  "HTTPStatusError",
55
57
  "ItemSelection",