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.
- {woltapi-0.2.0/src/woltapi.egg-info → woltapi-0.4.0}/PKG-INFO +118 -18
- {woltapi-0.2.0 → woltapi-0.4.0}/README.md +117 -17
- {woltapi-0.2.0 → woltapi-0.4.0}/examples/order.py +50 -127
- {woltapi-0.2.0 → woltapi-0.4.0}/pyproject.toml +1 -1
- {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi/__init__.py +2 -0
- woltapi-0.4.0/src/woltapi/basket.py +409 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi/client.py +82 -1
- {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi/selection.py +55 -14
- {woltapi-0.2.0 → woltapi-0.4.0/src/woltapi.egg-info}/PKG-INFO +118 -18
- {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi.egg-info/SOURCES.txt +2 -0
- woltapi-0.4.0/tests/test_basket.py +316 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/tests/test_client.py +262 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/tests/test_order_example.py +69 -111
- {woltapi-0.2.0 → woltapi-0.4.0}/tests/test_selection.py +173 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/LICENSE +0 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/MANIFEST.in +0 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/RELEASING.md +0 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/examples/browse.py +0 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/examples/check_session.py +0 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/setup.cfg +0 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi/auth.py +0 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi/credentials.py +0 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi/errors.py +0 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi/models.py +0 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi/purchase.py +0 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi/services.py +0 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi/transport.py +0 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi.egg-info/dependency_links.txt +0 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi.egg-info/requires.txt +0 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/src/woltapi.egg-info/top_level.txt +0 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/tests/__init__.py +0 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/tests/test_auth.py +0 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/tests/test_browse.py +0 -0
- {woltapi-0.2.0 → woltapi-0.4.0}/tests/test_check_session.py +0 -0
- {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.
|
|
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
|
|
278
|
-
delivery target/card, then asks before requesting a
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
rather than guessing for zero or multiple categories.
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
260
|
-
delivery target/card, then asks before requesting a
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
rather than guessing for zero or multiple categories.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
|
131
|
+
return selected, descriptions
|
|
161
132
|
|
|
162
133
|
|
|
163
|
-
def checkout(client, args
|
|
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
|
-
|
|
167
|
-
if
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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":
|
|
192
|
+
"country": venue_context.country,
|
|
256
193
|
"delivery_method": "homedelivery",
|
|
257
194
|
"available_methods": ["card"],
|
|
258
195
|
"items": [
|
|
259
196
|
{
|
|
260
|
-
"id": item
|
|
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=
|
|
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=
|
|
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
|
|
294
|
-
print(
|
|
295
|
-
|
|
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
|
|
307
|
+
checkout(client, args)
|
|
385
308
|
except CheckoutInputError as exc:
|
|
386
309
|
print(f"Checkout stopped: {exc}")
|
|
387
310
|
return 1
|
|
@@ -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",
|