woltapi 0.4.0__tar.gz → 0.5.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.4.0/src/woltapi.egg-info → woltapi-0.5.0}/PKG-INFO +55 -5
- {woltapi-0.4.0 → woltapi-0.5.0}/README.md +54 -4
- woltapi-0.5.0/examples/verify_saved_basket_rebuild.py +108 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/pyproject.toml +1 -1
- {woltapi-0.4.0 → woltapi-0.5.0}/src/woltapi/basket.py +10 -1
- {woltapi-0.4.0 → woltapi-0.5.0}/src/woltapi/client.py +24 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/src/woltapi/purchase.py +1 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/src/woltapi/transport.py +5 -0
- {woltapi-0.4.0 → woltapi-0.5.0/src/woltapi.egg-info}/PKG-INFO +55 -5
- {woltapi-0.4.0 → woltapi-0.5.0}/src/woltapi.egg-info/SOURCES.txt +3 -1
- {woltapi-0.4.0 → woltapi-0.5.0}/tests/test_basket.py +46 -1
- {woltapi-0.4.0 → woltapi-0.5.0}/tests/test_client.py +47 -0
- woltapi-0.5.0/tests/test_verify_saved_basket_rebuild_example.py +103 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/LICENSE +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/MANIFEST.in +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/RELEASING.md +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/examples/browse.py +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/examples/check_session.py +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/examples/order.py +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/setup.cfg +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/src/woltapi/__init__.py +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/src/woltapi/auth.py +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/src/woltapi/credentials.py +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/src/woltapi/errors.py +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/src/woltapi/models.py +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/src/woltapi/selection.py +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/src/woltapi/services.py +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/src/woltapi.egg-info/dependency_links.txt +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/src/woltapi.egg-info/requires.txt +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/src/woltapi.egg-info/top_level.txt +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/tests/__init__.py +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/tests/test_auth.py +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/tests/test_browse.py +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/tests/test_check_session.py +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/tests/test_order_example.py +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/tests/test_purchase.py +0 -0
- {woltapi-0.4.0 → woltapi-0.5.0}/tests/test_selection.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: woltapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.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
|
|
@@ -298,14 +298,16 @@ Item, option, and value IDs come from the assortment dictionary. For
|
|
|
298
298
|
current menu.
|
|
299
299
|
|
|
300
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
|
|
301
|
+
baskets you see in the Wolt app. The library can read, replace, and delete
|
|
302
|
+
them:
|
|
302
303
|
|
|
303
304
|
| Call | What it does |
|
|
304
305
|
| --- | --- |
|
|
305
306
|
| `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
307
|
| `client.save_basket(selection)` | Also replaces the whole server basket, but takes a full `OrderSelection`. |
|
|
307
308
|
| `client.get_basket_count()` | The number of baskets stored on the server. |
|
|
308
|
-
| `client.get_baskets_page(latitude, longitude)` |
|
|
309
|
+
| `client.get_baskets_page(latitude, longitude)` | Lists the saved baskets, as one page of the raw dictionary. It can contain personal data; do not log it raw. |
|
|
310
|
+
| `client.delete_baskets(basket_ids)` | Deletes the listed server baskets. |
|
|
309
311
|
| `client.get_venue_checkout_context(slug)` | The `VenueCheckoutContext` for a restaurant, read from its static page. |
|
|
310
312
|
|
|
311
313
|
There are two ways to save. `save_basket_items()` needs no saved address and no
|
|
@@ -315,8 +317,56 @@ after `list_delivery_targets()` and `get_payment_methods()` on the same client,
|
|
|
315
317
|
so it needs a saved delivery address and an enabled card. That cost is worth
|
|
316
318
|
paying only when you go on to request a checkout quote with the same selection.
|
|
317
319
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
+
There is no call that adds or removes a single item: every save replaces the
|
|
321
|
+
whole basket.
|
|
322
|
+
|
|
323
|
+
#### Delete a saved basket
|
|
324
|
+
|
|
325
|
+
`delete_baskets()` takes a list of basket IDs and deletes all of them in one
|
|
326
|
+
call. You get a basket ID from `get_baskets_page()`, or from the `SavedBasket`
|
|
327
|
+
that `save_basket()` and `save_basket_items()` return.
|
|
328
|
+
|
|
329
|
+
An empty list is rejected, because Wolt's behaviour in that case has never been
|
|
330
|
+
observed. So check the list before you send it, as both examples below do.
|
|
331
|
+
|
|
332
|
+
To delete every saved basket:
|
|
333
|
+
|
|
334
|
+
```python
|
|
335
|
+
page = client.get_baskets_page(60.17, 24.94)
|
|
336
|
+
ids = [entry["id"] for entry in page["baskets"]]
|
|
337
|
+
if ids:
|
|
338
|
+
client.delete_baskets(ids)
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
To delete the basket for one restaurant:
|
|
342
|
+
|
|
343
|
+
```python
|
|
344
|
+
page = client.get_baskets_page(60.17, 24.94)
|
|
345
|
+
ids = [
|
|
346
|
+
entry["id"] for entry in page["baskets"]
|
|
347
|
+
if entry["venue"]["slug"] == "<restaurant slug>"
|
|
348
|
+
]
|
|
349
|
+
if ids:
|
|
350
|
+
client.delete_baskets(ids)
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Every saved basket has items in it, because a basket only exists once you put
|
|
354
|
+
something in one. There is no empty saved basket to skip.
|
|
355
|
+
|
|
356
|
+
Wolt sends nothing back, so the call cannot tell you whether the baskets were
|
|
357
|
+
really there or whether anything was deleted. If you need to be sure, read
|
|
358
|
+
`get_basket_count()` or `get_baskets_page()` again afterwards.
|
|
359
|
+
|
|
360
|
+
Two limits of `get_baskets_page()` matter when you delete in bulk:
|
|
361
|
+
|
|
362
|
+
- It reads one page. The response carries a `start_after_time` cursor, which
|
|
363
|
+
suggests Wolt can split a long list, and the library does not follow that
|
|
364
|
+
cursor. Compare `len(page["baskets"])` with `get_basket_count()` to see
|
|
365
|
+
whether you have the whole list.
|
|
366
|
+
- It requires coordinates, and it is not known whether they only drive the
|
|
367
|
+
delivery estimates shown in the app or actually restrict which baskets come
|
|
368
|
+
back. If a basket you expect is missing, try coordinates near that
|
|
369
|
+
restaurant.
|
|
320
370
|
|
|
321
371
|
#### Change a saved basket
|
|
322
372
|
|
|
@@ -280,14 +280,16 @@ Item, option, and value IDs come from the assortment dictionary. For
|
|
|
280
280
|
current menu.
|
|
281
281
|
|
|
282
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
|
|
283
|
+
baskets you see in the Wolt app. The library can read, replace, and delete
|
|
284
|
+
them:
|
|
284
285
|
|
|
285
286
|
| Call | What it does |
|
|
286
287
|
| --- | --- |
|
|
287
288
|
| `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
289
|
| `client.save_basket(selection)` | Also replaces the whole server basket, but takes a full `OrderSelection`. |
|
|
289
290
|
| `client.get_basket_count()` | The number of baskets stored on the server. |
|
|
290
|
-
| `client.get_baskets_page(latitude, longitude)` |
|
|
291
|
+
| `client.get_baskets_page(latitude, longitude)` | Lists the saved baskets, as one page of the raw dictionary. It can contain personal data; do not log it raw. |
|
|
292
|
+
| `client.delete_baskets(basket_ids)` | Deletes the listed server baskets. |
|
|
291
293
|
| `client.get_venue_checkout_context(slug)` | The `VenueCheckoutContext` for a restaurant, read from its static page. |
|
|
292
294
|
|
|
293
295
|
There are two ways to save. `save_basket_items()` needs no saved address and no
|
|
@@ -297,8 +299,56 @@ after `list_delivery_targets()` and `get_payment_methods()` on the same client,
|
|
|
297
299
|
so it needs a saved delivery address and an enabled card. That cost is worth
|
|
298
300
|
paying only when you go on to request a checkout quote with the same selection.
|
|
299
301
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
+
There is no call that adds or removes a single item: every save replaces the
|
|
303
|
+
whole basket.
|
|
304
|
+
|
|
305
|
+
#### Delete a saved basket
|
|
306
|
+
|
|
307
|
+
`delete_baskets()` takes a list of basket IDs and deletes all of them in one
|
|
308
|
+
call. You get a basket ID from `get_baskets_page()`, or from the `SavedBasket`
|
|
309
|
+
that `save_basket()` and `save_basket_items()` return.
|
|
310
|
+
|
|
311
|
+
An empty list is rejected, because Wolt's behaviour in that case has never been
|
|
312
|
+
observed. So check the list before you send it, as both examples below do.
|
|
313
|
+
|
|
314
|
+
To delete every saved basket:
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
page = client.get_baskets_page(60.17, 24.94)
|
|
318
|
+
ids = [entry["id"] for entry in page["baskets"]]
|
|
319
|
+
if ids:
|
|
320
|
+
client.delete_baskets(ids)
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
To delete the basket for one restaurant:
|
|
324
|
+
|
|
325
|
+
```python
|
|
326
|
+
page = client.get_baskets_page(60.17, 24.94)
|
|
327
|
+
ids = [
|
|
328
|
+
entry["id"] for entry in page["baskets"]
|
|
329
|
+
if entry["venue"]["slug"] == "<restaurant slug>"
|
|
330
|
+
]
|
|
331
|
+
if ids:
|
|
332
|
+
client.delete_baskets(ids)
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Every saved basket has items in it, because a basket only exists once you put
|
|
336
|
+
something in one. There is no empty saved basket to skip.
|
|
337
|
+
|
|
338
|
+
Wolt sends nothing back, so the call cannot tell you whether the baskets were
|
|
339
|
+
really there or whether anything was deleted. If you need to be sure, read
|
|
340
|
+
`get_basket_count()` or `get_baskets_page()` again afterwards.
|
|
341
|
+
|
|
342
|
+
Two limits of `get_baskets_page()` matter when you delete in bulk:
|
|
343
|
+
|
|
344
|
+
- It reads one page. The response carries a `start_after_time` cursor, which
|
|
345
|
+
suggests Wolt can split a long list, and the library does not follow that
|
|
346
|
+
cursor. Compare `len(page["baskets"])` with `get_basket_count()` to see
|
|
347
|
+
whether you have the whole list.
|
|
348
|
+
- It requires coordinates, and it is not known whether they only drive the
|
|
349
|
+
delivery estimates shown in the app or actually restrict which baskets come
|
|
350
|
+
back. If a basket you expect is missing, try coordinates near that
|
|
351
|
+
restaurant.
|
|
302
352
|
|
|
303
353
|
#### Change a saved basket
|
|
304
354
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Verify saved baskets can be rebuilt from their current assortments.
|
|
3
|
+
|
|
4
|
+
Read-only: this script only reads saved baskets and assortments. Its output is
|
|
5
|
+
restricted to venue slugs, item counts, and integer cent totals.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from browse import TokenFileError, check_token_file, refresh_credentials
|
|
15
|
+
|
|
16
|
+
from woltapi import Basket, WoltApiError, WoltClient
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class VerificationInputError(ValueError):
|
|
20
|
+
"""A local saved-basket shape problem safe to report to the owner."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def venue_slug(saved_basket):
|
|
24
|
+
"""Return the saved basket's public venue slug without exposing other fields."""
|
|
25
|
+
if not isinstance(saved_basket, Mapping):
|
|
26
|
+
raise VerificationInputError("Saved basket is not an object.")
|
|
27
|
+
venue = saved_basket.get("venue")
|
|
28
|
+
if not isinstance(venue, Mapping):
|
|
29
|
+
raise VerificationInputError("Saved basket venue is not an object.")
|
|
30
|
+
slug = venue.get("slug")
|
|
31
|
+
if not isinstance(slug, str) or re.fullmatch(r"[a-z0-9][a-z0-9-]*", slug) is None:
|
|
32
|
+
raise VerificationInputError("Saved basket venue has no usable slug.")
|
|
33
|
+
return slug
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def failure_line(slug, error):
|
|
37
|
+
"""Format only local static error messages, never response-derived data."""
|
|
38
|
+
if isinstance(error, VerificationInputError):
|
|
39
|
+
message = str(error)
|
|
40
|
+
else:
|
|
41
|
+
message = "Details omitted."
|
|
42
|
+
return f"FAIL venue={slug}: {type(error).__name__}: {message}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def verify_baskets(client, args):
|
|
46
|
+
"""Read, rebuild, and report each saved basket; return whether all passed."""
|
|
47
|
+
try:
|
|
48
|
+
page = client.get_baskets_page(args.latitude, args.longitude)
|
|
49
|
+
baskets = page.get("baskets") if isinstance(page, Mapping) else None
|
|
50
|
+
if not isinstance(baskets, list):
|
|
51
|
+
raise VerificationInputError("Saved baskets page has no baskets list.")
|
|
52
|
+
except (VerificationInputError, WoltApiError, TypeError, ValueError) as exc:
|
|
53
|
+
print(failure_line("unknown", exc))
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
if not baskets:
|
|
57
|
+
# Keep the zero-basket note in the same privacy-safe output format.
|
|
58
|
+
print("PASS venue=no-saved-baskets items=0 total=0")
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
passed = True
|
|
62
|
+
for saved_basket in baskets:
|
|
63
|
+
slug = "unknown"
|
|
64
|
+
try:
|
|
65
|
+
slug = venue_slug(saved_basket)
|
|
66
|
+
assortment = client.get_assortment(slug)
|
|
67
|
+
basket = Basket.from_saved_basket(assortment, saved_basket, args.language)
|
|
68
|
+
selections = basket.item_selections()
|
|
69
|
+
item_count = sum(selection.count for selection in selections)
|
|
70
|
+
total = sum(selection.basket_price for selection in selections)
|
|
71
|
+
print(f"PASS venue={slug} items={item_count} total={total}")
|
|
72
|
+
except (VerificationInputError, WoltApiError, TypeError, ValueError) as exc:
|
|
73
|
+
print(failure_line(slug, exc))
|
|
74
|
+
passed = False
|
|
75
|
+
return passed
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main():
|
|
79
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
80
|
+
parser.add_argument("--latitude", type=float, required=True)
|
|
81
|
+
parser.add_argument("--longitude", type=float, required=True)
|
|
82
|
+
parser.add_argument("--token-file", type=Path, required=True)
|
|
83
|
+
parser.add_argument("--language", default="en")
|
|
84
|
+
args = parser.parse_args()
|
|
85
|
+
if not (
|
|
86
|
+
math.isfinite(args.latitude)
|
|
87
|
+
and -90 <= args.latitude <= 90
|
|
88
|
+
and math.isfinite(args.longitude)
|
|
89
|
+
and -180 <= args.longitude <= 180
|
|
90
|
+
):
|
|
91
|
+
parser.error("Provide finite latitude [-90, 90] and longitude [-180, 180].")
|
|
92
|
+
check_token_file(parser, args.token_file)
|
|
93
|
+
try:
|
|
94
|
+
client = WoltClient(refresh_credentials(args.language, args.token_file))
|
|
95
|
+
return 0 if verify_baskets(client, args) else 1
|
|
96
|
+
except TokenFileError:
|
|
97
|
+
print("FAIL venue=unknown: TokenFileError: Could not save the refresh token.")
|
|
98
|
+
return 1
|
|
99
|
+
except (EOFError, KeyboardInterrupt) as exc:
|
|
100
|
+
print(f"FAIL venue=unknown: {type(exc).__name__}: Cancelled.")
|
|
101
|
+
return 1
|
|
102
|
+
except (OSError, UnicodeError, ValueError, WoltApiError) as exc:
|
|
103
|
+
print(failure_line("unknown", exc))
|
|
104
|
+
return 1
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
raise SystemExit(main())
|
|
@@ -265,6 +265,10 @@ def _index_by_id(value: Any) -> dict[str, Mapping[str, Any]]:
|
|
|
265
265
|
|
|
266
266
|
|
|
267
267
|
def _translated_name(value: Any, language: str) -> str:
|
|
268
|
+
# Catalog/read endpoints return strings; only restaurant-api purchase payloads
|
|
269
|
+
# (/v2/purchases, /v1/post-checkout-config) use list-of-{lang, value} names.
|
|
270
|
+
if _is_nonempty_text(value):
|
|
271
|
+
return value
|
|
268
272
|
if not _is_array(value):
|
|
269
273
|
raise SelectionError("The selected item is missing translated names.")
|
|
270
274
|
for translation in value:
|
|
@@ -274,7 +278,12 @@ def _translated_name(value: Any, language: str) -> str:
|
|
|
274
278
|
name = translation.get("value")
|
|
275
279
|
if translation_language == language and _is_nonempty_text(name):
|
|
276
280
|
return name
|
|
277
|
-
|
|
281
|
+
for translation in value:
|
|
282
|
+
if isinstance(translation, Mapping) and _is_nonempty_text(
|
|
283
|
+
name := translation.get("value")
|
|
284
|
+
):
|
|
285
|
+
return name
|
|
286
|
+
raise SelectionError("The selected item has no usable name.")
|
|
278
287
|
|
|
279
288
|
|
|
280
289
|
def _required_fields(
|
|
@@ -149,6 +149,22 @@ class WoltClient:
|
|
|
149
149
|
query={"lat": latitude, "lon": longitude},
|
|
150
150
|
)
|
|
151
151
|
|
|
152
|
+
def delete_baskets(self, basket_ids: Sequence[str]) -> None:
|
|
153
|
+
"""Delete server baskets without confirmation from the response.
|
|
154
|
+
|
|
155
|
+
The response carries no result, so a caller who wants to confirm must
|
|
156
|
+
re-read get_basket_count() or get_baskets_page().
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
basket_ids = _basket_ids(basket_ids)
|
|
160
|
+
self._transport.request(
|
|
161
|
+
ServiceHost.CONSUMER,
|
|
162
|
+
"POST",
|
|
163
|
+
"/order-xp/v1/baskets/bulk/delete",
|
|
164
|
+
json_body={"ids": basket_ids},
|
|
165
|
+
expect_response_body=False,
|
|
166
|
+
)
|
|
167
|
+
|
|
152
168
|
def get_venue_static(self, venue_slug: str) -> dict[str, Any]:
|
|
153
169
|
"""Read the static venue page for a slug."""
|
|
154
170
|
|
|
@@ -546,6 +562,14 @@ def _required_text(value: object, name: str) -> str:
|
|
|
546
562
|
return value
|
|
547
563
|
|
|
548
564
|
|
|
565
|
+
def _basket_ids(value: object) -> list[str]:
|
|
566
|
+
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
|
567
|
+
raise TypeError("basket_ids must be a sequence.")
|
|
568
|
+
if not value:
|
|
569
|
+
raise ValueError("basket_ids must not be empty.")
|
|
570
|
+
return [_required_text(basket_id, "basket ID") for basket_id in value]
|
|
571
|
+
|
|
572
|
+
|
|
549
573
|
def _path_segment(value: object, name: str) -> str:
|
|
550
574
|
return quote(_required_text(value, name), safe="")
|
|
551
575
|
|
|
@@ -937,6 +937,7 @@ def _purchase_option_summary(option: Mapping[str, Any]) -> PurchaseOptionSummary
|
|
|
937
937
|
|
|
938
938
|
|
|
939
939
|
def _purchase_name_summaries(names: Any, label: str) -> tuple[PurchaseNameSummary, ...]:
|
|
940
|
+
# This list shape is correct for /v2/purchases; do not accept strings (verified by capture).
|
|
940
941
|
if not _is_array(names) or not names:
|
|
941
942
|
raise PurchasePreparationError(f"{label} are incomplete.")
|
|
942
943
|
summaries: list[PurchaseNameSummary] = []
|
|
@@ -74,6 +74,7 @@ class WoltTransport:
|
|
|
74
74
|
*,
|
|
75
75
|
query: Mapping[str, Any] | None = None,
|
|
76
76
|
json_body: Mapping[str, Any] | None = None,
|
|
77
|
+
expect_response_body: bool = True,
|
|
77
78
|
) -> dict[str, Any]:
|
|
78
79
|
"""Make one JSON request to a fixed host without retries or redirects."""
|
|
79
80
|
|
|
@@ -87,6 +88,8 @@ class WoltTransport:
|
|
|
87
88
|
raise TypeError("query must be a mapping when provided.")
|
|
88
89
|
if json_body is not None and not isinstance(json_body, Mapping):
|
|
89
90
|
raise TypeError("json_body must be a mapping when provided.")
|
|
91
|
+
if not isinstance(expect_response_body, bool):
|
|
92
|
+
raise TypeError("expect_response_body must be a bool.")
|
|
90
93
|
|
|
91
94
|
data = _encode_json(json_body, service) if json_body is not None else None
|
|
92
95
|
headers = _request_headers(
|
|
@@ -116,6 +119,8 @@ class WoltTransport:
|
|
|
116
119
|
|
|
117
120
|
if not 200 <= status_code < 300:
|
|
118
121
|
raise HTTPStatusError(service.value, status_code)
|
|
122
|
+
if not expect_response_body:
|
|
123
|
+
return {}
|
|
119
124
|
return _decode_json_object(response_body, service)
|
|
120
125
|
|
|
121
126
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: woltapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.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
|
|
@@ -298,14 +298,16 @@ Item, option, and value IDs come from the assortment dictionary. For
|
|
|
298
298
|
current menu.
|
|
299
299
|
|
|
300
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
|
|
301
|
+
baskets you see in the Wolt app. The library can read, replace, and delete
|
|
302
|
+
them:
|
|
302
303
|
|
|
303
304
|
| Call | What it does |
|
|
304
305
|
| --- | --- |
|
|
305
306
|
| `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
307
|
| `client.save_basket(selection)` | Also replaces the whole server basket, but takes a full `OrderSelection`. |
|
|
307
308
|
| `client.get_basket_count()` | The number of baskets stored on the server. |
|
|
308
|
-
| `client.get_baskets_page(latitude, longitude)` |
|
|
309
|
+
| `client.get_baskets_page(latitude, longitude)` | Lists the saved baskets, as one page of the raw dictionary. It can contain personal data; do not log it raw. |
|
|
310
|
+
| `client.delete_baskets(basket_ids)` | Deletes the listed server baskets. |
|
|
309
311
|
| `client.get_venue_checkout_context(slug)` | The `VenueCheckoutContext` for a restaurant, read from its static page. |
|
|
310
312
|
|
|
311
313
|
There are two ways to save. `save_basket_items()` needs no saved address and no
|
|
@@ -315,8 +317,56 @@ after `list_delivery_targets()` and `get_payment_methods()` on the same client,
|
|
|
315
317
|
so it needs a saved delivery address and an enabled card. That cost is worth
|
|
316
318
|
paying only when you go on to request a checkout quote with the same selection.
|
|
317
319
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
+
There is no call that adds or removes a single item: every save replaces the
|
|
321
|
+
whole basket.
|
|
322
|
+
|
|
323
|
+
#### Delete a saved basket
|
|
324
|
+
|
|
325
|
+
`delete_baskets()` takes a list of basket IDs and deletes all of them in one
|
|
326
|
+
call. You get a basket ID from `get_baskets_page()`, or from the `SavedBasket`
|
|
327
|
+
that `save_basket()` and `save_basket_items()` return.
|
|
328
|
+
|
|
329
|
+
An empty list is rejected, because Wolt's behaviour in that case has never been
|
|
330
|
+
observed. So check the list before you send it, as both examples below do.
|
|
331
|
+
|
|
332
|
+
To delete every saved basket:
|
|
333
|
+
|
|
334
|
+
```python
|
|
335
|
+
page = client.get_baskets_page(60.17, 24.94)
|
|
336
|
+
ids = [entry["id"] for entry in page["baskets"]]
|
|
337
|
+
if ids:
|
|
338
|
+
client.delete_baskets(ids)
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
To delete the basket for one restaurant:
|
|
342
|
+
|
|
343
|
+
```python
|
|
344
|
+
page = client.get_baskets_page(60.17, 24.94)
|
|
345
|
+
ids = [
|
|
346
|
+
entry["id"] for entry in page["baskets"]
|
|
347
|
+
if entry["venue"]["slug"] == "<restaurant slug>"
|
|
348
|
+
]
|
|
349
|
+
if ids:
|
|
350
|
+
client.delete_baskets(ids)
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Every saved basket has items in it, because a basket only exists once you put
|
|
354
|
+
something in one. There is no empty saved basket to skip.
|
|
355
|
+
|
|
356
|
+
Wolt sends nothing back, so the call cannot tell you whether the baskets were
|
|
357
|
+
really there or whether anything was deleted. If you need to be sure, read
|
|
358
|
+
`get_basket_count()` or `get_baskets_page()` again afterwards.
|
|
359
|
+
|
|
360
|
+
Two limits of `get_baskets_page()` matter when you delete in bulk:
|
|
361
|
+
|
|
362
|
+
- It reads one page. The response carries a `start_after_time` cursor, which
|
|
363
|
+
suggests Wolt can split a long list, and the library does not follow that
|
|
364
|
+
cursor. Compare `len(page["baskets"])` with `get_basket_count()` to see
|
|
365
|
+
whether you have the whole list.
|
|
366
|
+
- It requires coordinates, and it is not known whether they only drive the
|
|
367
|
+
delivery estimates shown in the app or actually restrict which baskets come
|
|
368
|
+
back. If a basket you expect is missing, try coordinates near that
|
|
369
|
+
restaurant.
|
|
320
370
|
|
|
321
371
|
#### Change a saved basket
|
|
322
372
|
|
|
@@ -6,6 +6,7 @@ pyproject.toml
|
|
|
6
6
|
examples/browse.py
|
|
7
7
|
examples/check_session.py
|
|
8
8
|
examples/order.py
|
|
9
|
+
examples/verify_saved_basket_rebuild.py
|
|
9
10
|
src/woltapi/__init__.py
|
|
10
11
|
src/woltapi/auth.py
|
|
11
12
|
src/woltapi/basket.py
|
|
@@ -30,4 +31,5 @@ tests/test_check_session.py
|
|
|
30
31
|
tests/test_client.py
|
|
31
32
|
tests/test_order_example.py
|
|
32
33
|
tests/test_purchase.py
|
|
33
|
-
tests/test_selection.py
|
|
34
|
+
tests/test_selection.py
|
|
35
|
+
tests/test_verify_saved_basket_rebuild_example.py
|
|
@@ -81,11 +81,25 @@ def assortment() -> dict[str, Any]:
|
|
|
81
81
|
"vat_percentage_decimal": "14.0",
|
|
82
82
|
"options": [],
|
|
83
83
|
},
|
|
84
|
+
{
|
|
85
|
+
"id": "item-waffle",
|
|
86
|
+
"name": "Βέλγικη Βάφλα",
|
|
87
|
+
"price": 525,
|
|
88
|
+
"checksum": "checksum-waffle",
|
|
89
|
+
"allowed_delivery_methods": ["homedelivery"],
|
|
90
|
+
"restrictions": [],
|
|
91
|
+
"alcohol_permille": 0,
|
|
92
|
+
"product_hierarchy_tags": ["food", "dessert"],
|
|
93
|
+
"vat_percentage": 14,
|
|
94
|
+
"vat_percentage_decimal": "14.0",
|
|
95
|
+
"options": [],
|
|
96
|
+
},
|
|
84
97
|
],
|
|
85
98
|
"categories": [
|
|
86
99
|
{"id": "pizza", "item_ids": ["item-1"]},
|
|
87
100
|
{"id": "salad", "item_ids": ["item-2"]},
|
|
88
101
|
{"id": "other", "item_ids": ["item-removed"]},
|
|
102
|
+
{"id": "dessert", "item_ids": ["item-waffle"]},
|
|
89
103
|
],
|
|
90
104
|
"options": [
|
|
91
105
|
{
|
|
@@ -234,7 +248,6 @@ def test_basket_mutation_rejects_duplicates_and_contents_are_detached() -> None:
|
|
|
234
248
|
lambda data: data["categories"][0]["item_ids"].clear(),
|
|
235
249
|
lambda data: data["items"][0].update(price=1.5),
|
|
236
250
|
lambda data: data["options"][0]["values"][0].update(price=True),
|
|
237
|
-
lambda data: data["items"][0].update(name=[{"lang": "fi", "value": "Pizza"}]),
|
|
238
251
|
],
|
|
239
252
|
)
|
|
240
253
|
def test_basket_rejects_missing_catalog_fields_invalid_prices_and_names(change) -> None:
|
|
@@ -246,6 +259,25 @@ def test_basket_rejects_missing_catalog_fields_invalid_prices_and_names(change)
|
|
|
246
259
|
basket.add_item("item-1", options=[topping("pizza-toppings", "cheese")])
|
|
247
260
|
|
|
248
261
|
|
|
262
|
+
def test_basket_falls_back_to_first_list_name_without_language_match() -> None:
|
|
263
|
+
data = assortment()
|
|
264
|
+
data["items"][0]["name"] = [{"lang": "fi", "value": "Pizza"}]
|
|
265
|
+
|
|
266
|
+
basket = Basket(data, "en")
|
|
267
|
+
basket.add_item("item-1")
|
|
268
|
+
|
|
269
|
+
assert basket.item_selections()[0].basket_name == "Pizza"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_basket_rejects_unusable_name_shape() -> None:
|
|
273
|
+
data = assortment()
|
|
274
|
+
data["items"][0]["name"] = {"value": "Pizza"}
|
|
275
|
+
|
|
276
|
+
basket = Basket(data, "en")
|
|
277
|
+
with pytest.raises(SelectionError):
|
|
278
|
+
basket.add_item("item-1")
|
|
279
|
+
|
|
280
|
+
|
|
249
281
|
def test_basket_copies_assortment_and_option_input() -> None:
|
|
250
282
|
data = assortment()
|
|
251
283
|
selected = [topping("pizza-toppings", "cheese")]
|
|
@@ -275,6 +307,19 @@ def test_basket_from_saved_basket_rebuilds_catalog_prices_and_options() -> None:
|
|
|
275
307
|
assert basket.item_selections()[0].substitution_allowed is True
|
|
276
308
|
|
|
277
309
|
|
|
310
|
+
def test_basket_uses_plain_string_catalog_names_when_adding_and_rebuilding() -> None:
|
|
311
|
+
basket = Basket(assortment(), "en")
|
|
312
|
+
basket.add_item("item-waffle")
|
|
313
|
+
|
|
314
|
+
assert basket.item_selections()[0].basket_name == "Βέλγικη Βάφλα"
|
|
315
|
+
|
|
316
|
+
saved = saved_basket()
|
|
317
|
+
saved["items"][0].update(id="item-waffle", options=[])
|
|
318
|
+
rebuilt = Basket.from_saved_basket(assortment(), saved, "en")
|
|
319
|
+
|
|
320
|
+
assert rebuilt.item_selections()[0].basket_name == "Βέλγικη Βάφλα"
|
|
321
|
+
|
|
322
|
+
|
|
278
323
|
def test_basket_from_saved_basket_skips_unchosen_option_configurations() -> None:
|
|
279
324
|
saved = saved_basket()
|
|
280
325
|
saved["items"][0]["options"].append({"id": "pizza-toppings", "values": []})
|
|
@@ -17,6 +17,7 @@ from woltapi import (
|
|
|
17
17
|
WoltClient,
|
|
18
18
|
WoltTransport,
|
|
19
19
|
)
|
|
20
|
+
from woltapi.services import ServiceHost
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
class FakeResponse:
|
|
@@ -261,6 +262,52 @@ class WoltClientTests(unittest.TestCase):
|
|
|
261
262
|
self.assertEqual(headers["x-consumer-session"], "consumer-secret")
|
|
262
263
|
self.assertNotIn("x-restaurant-session", headers)
|
|
263
264
|
|
|
265
|
+
def test_delete_baskets_posts_ids_and_accepts_null_response(self) -> None:
|
|
266
|
+
client, opener = make_client(FakeResponse(None))
|
|
267
|
+
|
|
268
|
+
result = client.delete_baskets(["basket-1", "basket-2"])
|
|
269
|
+
|
|
270
|
+
self.assertIsNone(result)
|
|
271
|
+
self.assertEqual(len(opener.requests), 1)
|
|
272
|
+
request = opener.requests[0]
|
|
273
|
+
self.assertEqual(request.method, "POST")
|
|
274
|
+
self.assertEqual(urlsplit(request.full_url).netloc, "consumer-api.wolt.com")
|
|
275
|
+
self.assertEqual(
|
|
276
|
+
urlsplit(request.full_url).path,
|
|
277
|
+
"/order-xp/v1/baskets/bulk/delete",
|
|
278
|
+
)
|
|
279
|
+
headers = _headers(request)
|
|
280
|
+
self.assertEqual(headers["x-consumer-session"], "consumer-secret")
|
|
281
|
+
self.assertEqual(
|
|
282
|
+
json.loads(request.data.decode("utf-8")), {"ids": ["basket-1", "basket-2"]}
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def test_transport_rejects_null_body_when_response_is_expected(self) -> None:
|
|
286
|
+
client, _ = make_client(FakeResponse(None))
|
|
287
|
+
|
|
288
|
+
with self.assertRaises(ResponseShapeError):
|
|
289
|
+
client._transport.request(
|
|
290
|
+
ServiceHost.CONSUMER, "POST", "/order-xp/v1/baskets/bulk/delete"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def test_delete_baskets_validates_ids_before_request(self) -> None:
|
|
294
|
+
for basket_ids, error_type in (
|
|
295
|
+
([], ValueError),
|
|
296
|
+
("basket-1", TypeError),
|
|
297
|
+
(None, TypeError),
|
|
298
|
+
(1, TypeError),
|
|
299
|
+
(b"basket-1", TypeError),
|
|
300
|
+
([1], ValueError),
|
|
301
|
+
([" "], ValueError),
|
|
302
|
+
):
|
|
303
|
+
with self.subTest(basket_ids=basket_ids):
|
|
304
|
+
client, opener = make_client()
|
|
305
|
+
|
|
306
|
+
with self.assertRaises(error_type):
|
|
307
|
+
client.delete_baskets(basket_ids) # type: ignore[arg-type]
|
|
308
|
+
|
|
309
|
+
self.assertEqual(opener.requests, [])
|
|
310
|
+
|
|
264
311
|
def test_get_basket_count_rejects_non_integer_counts(self) -> None:
|
|
265
312
|
for payload in ({}, {"count": True}, {"count": "1"}):
|
|
266
313
|
with self.subTest(payload=payload):
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import runpy
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from urllib.parse import urlsplit
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from tests.test_basket import assortment, saved_basket
|
|
9
|
+
from tests.test_client import FakeResponse, make_client
|
|
10
|
+
from woltapi import SelectionError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_example(monkeypatch):
|
|
14
|
+
examples = Path(__file__).resolve().parents[1] / "examples"
|
|
15
|
+
monkeypatch.syspath_prepend(str(examples))
|
|
16
|
+
return runpy.run_path(str(examples / "verify_saved_basket_rebuild.py"))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_verify_baskets_rebuilds_with_read_only_requests(monkeypatch, capsys):
|
|
20
|
+
example = load_example(monkeypatch)
|
|
21
|
+
client, opener = make_client(
|
|
22
|
+
FakeResponse({"baskets": [saved_basket()]}), FakeResponse(assortment())
|
|
23
|
+
)
|
|
24
|
+
args = argparse.Namespace(latitude=60.17, longitude=24.94, language="en")
|
|
25
|
+
|
|
26
|
+
assert example["verify_baskets"](client, args) is True
|
|
27
|
+
|
|
28
|
+
assert capsys.readouterr().out == "PASS venue=pizza-place items=2 total=1630\n"
|
|
29
|
+
assert [
|
|
30
|
+
(request.get_method(), urlsplit(request.full_url).path)
|
|
31
|
+
for request in opener.requests
|
|
32
|
+
] == [
|
|
33
|
+
("GET", "/order-xp/web/v1/pages/baskets"),
|
|
34
|
+
(
|
|
35
|
+
"GET",
|
|
36
|
+
"/consumer-api/consumer-assortment/v1/venues/slug/pizza-place/assortment",
|
|
37
|
+
),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_verify_baskets_reports_zero_baskets_without_extra_requests(
|
|
42
|
+
monkeypatch, capsys
|
|
43
|
+
):
|
|
44
|
+
example = load_example(monkeypatch)
|
|
45
|
+
client, opener = make_client(FakeResponse({"baskets": []}))
|
|
46
|
+
args = argparse.Namespace(latitude=60.17, longitude=24.94, language="en")
|
|
47
|
+
|
|
48
|
+
assert example["verify_baskets"](client, args) is True
|
|
49
|
+
|
|
50
|
+
assert capsys.readouterr().out == "PASS venue=no-saved-baskets items=0 total=0\n"
|
|
51
|
+
assert len(opener.requests) == 1
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_verify_baskets_omits_private_malformed_page_data(monkeypatch, capsys):
|
|
55
|
+
example = load_example(monkeypatch)
|
|
56
|
+
client, _ = make_client(FakeResponse({"baskets": "PRIVATE BASKET DATA"}))
|
|
57
|
+
args = argparse.Namespace(latitude=60.17, longitude=24.94, language="en")
|
|
58
|
+
|
|
59
|
+
assert example["verify_baskets"](client, args) is False
|
|
60
|
+
|
|
61
|
+
output = capsys.readouterr().out
|
|
62
|
+
assert output == (
|
|
63
|
+
"FAIL venue=unknown: VerificationInputError: "
|
|
64
|
+
"Saved baskets page has no baskets list.\n"
|
|
65
|
+
)
|
|
66
|
+
assert "PRIVATE" not in output
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_verify_baskets_omits_private_library_error_message(monkeypatch, capsys):
|
|
70
|
+
example = load_example(monkeypatch)
|
|
71
|
+
args = argparse.Namespace(latitude=60.17, longitude=24.94, language="en")
|
|
72
|
+
|
|
73
|
+
class FailingClient:
|
|
74
|
+
def get_baskets_page(self, latitude, longitude):
|
|
75
|
+
return {"baskets": [saved_basket()]}
|
|
76
|
+
|
|
77
|
+
def get_assortment(self, slug):
|
|
78
|
+
raise SelectionError("PRIVATE LIBRARY ERROR")
|
|
79
|
+
|
|
80
|
+
assert example["verify_baskets"](FailingClient(), args) is False
|
|
81
|
+
|
|
82
|
+
output = capsys.readouterr().out
|
|
83
|
+
assert output == "FAIL venue=pizza-place: SelectionError: Details omitted.\n"
|
|
84
|
+
assert "PRIVATE" not in output
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.mark.parametrize(
|
|
88
|
+
"slug", ["pizza place", "pizza\x00place", "Pizza-place", "pizza_place"]
|
|
89
|
+
)
|
|
90
|
+
def test_verify_baskets_rejects_invalid_venue_slug(monkeypatch, capsys, slug):
|
|
91
|
+
example = load_example(monkeypatch)
|
|
92
|
+
basket = saved_basket()
|
|
93
|
+
basket["venue"]["slug"] = slug
|
|
94
|
+
client, opener = make_client(FakeResponse({"baskets": [basket]}))
|
|
95
|
+
args = argparse.Namespace(latitude=60.17, longitude=24.94, language="en")
|
|
96
|
+
|
|
97
|
+
assert example["verify_baskets"](client, args) is False
|
|
98
|
+
|
|
99
|
+
assert capsys.readouterr().out == (
|
|
100
|
+
"FAIL venue=unknown: VerificationInputError: "
|
|
101
|
+
"Saved basket venue has no usable slug.\n"
|
|
102
|
+
)
|
|
103
|
+
assert len(opener.requests) == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|