woltapi 0.3.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.3.0/src/woltapi.egg-info → woltapi-0.4.0}/PKG-INFO +58 -4
- {woltapi-0.3.0 → woltapi-0.4.0}/README.md +57 -3
- {woltapi-0.3.0 → woltapi-0.4.0}/examples/order.py +8 -45
- {woltapi-0.3.0 → woltapi-0.4.0}/pyproject.toml +1 -1
- {woltapi-0.3.0 → woltapi-0.4.0}/src/woltapi/basket.py +113 -1
- {woltapi-0.3.0 → woltapi-0.4.0}/src/woltapi/client.py +49 -17
- {woltapi-0.3.0 → woltapi-0.4.0}/src/woltapi/selection.py +32 -0
- {woltapi-0.3.0 → woltapi-0.4.0/src/woltapi.egg-info}/PKG-INFO +58 -4
- {woltapi-0.3.0 → woltapi-0.4.0}/tests/test_basket.py +87 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/tests/test_client.py +227 -30
- {woltapi-0.3.0 → woltapi-0.4.0}/tests/test_order_example.py +7 -33
- {woltapi-0.3.0 → woltapi-0.4.0}/tests/test_selection.py +80 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/LICENSE +0 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/MANIFEST.in +0 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/RELEASING.md +0 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/examples/browse.py +0 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/examples/check_session.py +0 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/setup.cfg +0 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/src/woltapi/__init__.py +0 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/src/woltapi/auth.py +0 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/src/woltapi/credentials.py +0 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/src/woltapi/errors.py +0 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/src/woltapi/models.py +0 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/src/woltapi/purchase.py +0 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/src/woltapi/services.py +0 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/src/woltapi/transport.py +0 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/src/woltapi.egg-info/SOURCES.txt +0 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/src/woltapi.egg-info/dependency_links.txt +0 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/src/woltapi.egg-info/requires.txt +0 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/src/woltapi.egg-info/top_level.txt +0 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/tests/__init__.py +0 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/tests/test_auth.py +0 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/tests/test_browse.py +0 -0
- {woltapi-0.3.0 → woltapi-0.4.0}/tests/test_check_session.py +0 -0
- {woltapi-0.3.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
|
|
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
159
|
-
if
|
|
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
|
|
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 =
|
|
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":
|
|
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=
|
|
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
|
|
307
|
+
checkout(client, args)
|
|
345
308
|
except CheckoutInputError as exc:
|
|
346
309
|
print(f"Checkout stopped: {exc}")
|
|
347
310
|
return 1
|
|
@@ -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."""
|
|
@@ -267,6 +291,94 @@ def _copy_options(value: Any) -> tuple[OptionSelection, ...]:
|
|
|
267
291
|
return tuple(deepcopy(value))
|
|
268
292
|
|
|
269
293
|
|
|
294
|
+
def _saved_basket_items(
|
|
295
|
+
value: Any,
|
|
296
|
+
) -> tuple[tuple[str, int, tuple[OptionSelection, ...], bool], ...]:
|
|
297
|
+
if not isinstance(value, Mapping):
|
|
298
|
+
_saved_basket_shape_error()
|
|
299
|
+
_validate_saved_venue(value.get("venue"))
|
|
300
|
+
saved_items = value.get("items")
|
|
301
|
+
if not _is_array(saved_items):
|
|
302
|
+
_saved_basket_shape_error()
|
|
303
|
+
|
|
304
|
+
items = []
|
|
305
|
+
for saved_item in saved_items:
|
|
306
|
+
if not isinstance(saved_item, Mapping):
|
|
307
|
+
_saved_basket_shape_error()
|
|
308
|
+
item_id = _saved_nonempty_text(saved_item, "id")
|
|
309
|
+
_saved_nonempty_text(saved_item, "name")
|
|
310
|
+
if "price" not in saved_item:
|
|
311
|
+
_saved_basket_shape_error()
|
|
312
|
+
count = saved_item.get("count")
|
|
313
|
+
if not _is_integer(count) or count <= 0:
|
|
314
|
+
_saved_basket_shape_error()
|
|
315
|
+
substitution_allowed = _saved_substitution_allowed(
|
|
316
|
+
saved_item.get("substitution_settings")
|
|
317
|
+
)
|
|
318
|
+
if "options" not in saved_item:
|
|
319
|
+
_saved_basket_shape_error()
|
|
320
|
+
options = _saved_item_options(saved_item["options"])
|
|
321
|
+
items.append((item_id, count, options, substitution_allowed))
|
|
322
|
+
return tuple(items)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _validate_saved_venue(value: Any) -> None:
|
|
326
|
+
if not isinstance(value, Mapping):
|
|
327
|
+
_saved_basket_shape_error()
|
|
328
|
+
for field_name in ("id", "name", "slug", "country"):
|
|
329
|
+
_saved_nonempty_text(value, field_name)
|
|
330
|
+
if not isinstance(value.get("available"), bool):
|
|
331
|
+
_saved_basket_shape_error()
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _saved_substitution_allowed(value: Any) -> bool:
|
|
335
|
+
if not isinstance(value, Mapping):
|
|
336
|
+
_saved_basket_shape_error()
|
|
337
|
+
is_allowed = value.get("is_allowed")
|
|
338
|
+
if not isinstance(is_allowed, bool):
|
|
339
|
+
_saved_basket_shape_error()
|
|
340
|
+
return is_allowed
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _saved_item_options(value: Any) -> tuple[OptionSelection, ...]:
|
|
344
|
+
if value is None:
|
|
345
|
+
return ()
|
|
346
|
+
if not _is_array(value):
|
|
347
|
+
_saved_basket_shape_error()
|
|
348
|
+
|
|
349
|
+
options = []
|
|
350
|
+
for option in value:
|
|
351
|
+
if not isinstance(option, Mapping):
|
|
352
|
+
_saved_basket_shape_error()
|
|
353
|
+
configuration_id = _saved_nonempty_text(option, "id")
|
|
354
|
+
saved_values = option.get("values")
|
|
355
|
+
if not _is_array(saved_values):
|
|
356
|
+
_saved_basket_shape_error()
|
|
357
|
+
values = []
|
|
358
|
+
for saved_value in saved_values:
|
|
359
|
+
if not isinstance(saved_value, Mapping):
|
|
360
|
+
_saved_basket_shape_error()
|
|
361
|
+
value_id = _saved_nonempty_text(saved_value, "id")
|
|
362
|
+
count = saved_value.get("count")
|
|
363
|
+
if not _is_integer(count) or count <= 0:
|
|
364
|
+
_saved_basket_shape_error()
|
|
365
|
+
values.append(OptionValueSelection(value_id, count))
|
|
366
|
+
if values:
|
|
367
|
+
options.append(OptionSelection(configuration_id, values))
|
|
368
|
+
return tuple(options)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _saved_nonempty_text(value: Mapping[str, Any], field_name: str) -> str:
|
|
372
|
+
field_value = value.get(field_name)
|
|
373
|
+
if not _is_nonempty_text(field_value):
|
|
374
|
+
_saved_basket_shape_error()
|
|
375
|
+
return field_value
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _saved_basket_shape_error() -> None:
|
|
379
|
+
raise ResponseShapeError("consumer")
|
|
380
|
+
|
|
381
|
+
|
|
270
382
|
def _item_id(value: Any) -> str:
|
|
271
383
|
if not _is_nonempty_text(value):
|
|
272
384
|
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
|
|
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
|
|
|
@@ -354,6 +354,38 @@ class OrderSelection:
|
|
|
354
354
|
return self._identity
|
|
355
355
|
|
|
356
356
|
|
|
357
|
+
def _build_basket_payload(
|
|
358
|
+
assortment: Mapping[str, Any],
|
|
359
|
+
*,
|
|
360
|
+
venue: VenueCheckoutContext,
|
|
361
|
+
items: Sequence[ItemSelection],
|
|
362
|
+
) -> dict[str, Any]:
|
|
363
|
+
"""Build a basket payload without requiring delivery or payment data."""
|
|
364
|
+
|
|
365
|
+
if not isinstance(assortment, Mapping):
|
|
366
|
+
raise SelectionError("A selection requires an assortment mapping.")
|
|
367
|
+
if not isinstance(venue, VenueCheckoutContext):
|
|
368
|
+
raise TypeError("venue must be a VenueCheckoutContext instance.")
|
|
369
|
+
if not _is_array(items) or not items:
|
|
370
|
+
raise SelectionError("A selection requires at least one item.")
|
|
371
|
+
|
|
372
|
+
catalog_items = _index_by_id(assortment.get("items"))
|
|
373
|
+
root_options = _index_by_id(assortment.get("options"))
|
|
374
|
+
normalized_items = [
|
|
375
|
+
_normalize_item(item, catalog_items, root_options) for item in items
|
|
376
|
+
]
|
|
377
|
+
item_ids = [item["id"] for item in normalized_items]
|
|
378
|
+
if len(item_ids) != len(set(item_ids)):
|
|
379
|
+
raise SelectionError("A selection cannot contain duplicate item IDs.")
|
|
380
|
+
|
|
381
|
+
normalized_venue = _normalize_venue(venue)
|
|
382
|
+
return {
|
|
383
|
+
"venue_id": normalized_venue["id"],
|
|
384
|
+
"currency": normalized_venue["currency"],
|
|
385
|
+
"items": [_serialize_basket_item(item) for item in normalized_items],
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
|
|
357
389
|
_CHECKOUT_REQUIRED_FIELDS = {
|
|
358
390
|
"category_id",
|
|
359
391
|
"category_ids",
|
|
@@ -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
|
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
|