woltapi 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {woltapi-0.2.0/src/woltapi.egg-info → woltapi-0.3.0}/PKG-INFO +64 -18
  2. {woltapi-0.2.0 → woltapi-0.3.0}/README.md +63 -17
  3. {woltapi-0.2.0 → woltapi-0.3.0}/examples/order.py +45 -85
  4. {woltapi-0.2.0 → woltapi-0.3.0}/pyproject.toml +1 -1
  5. {woltapi-0.2.0 → woltapi-0.3.0}/src/woltapi/__init__.py +2 -0
  6. woltapi-0.3.0/src/woltapi/basket.py +297 -0
  7. {woltapi-0.2.0 → woltapi-0.3.0}/src/woltapi/client.py +51 -2
  8. {woltapi-0.2.0 → woltapi-0.3.0}/src/woltapi/selection.py +23 -14
  9. {woltapi-0.2.0 → woltapi-0.3.0/src/woltapi.egg-info}/PKG-INFO +64 -18
  10. {woltapi-0.2.0 → woltapi-0.3.0}/src/woltapi.egg-info/SOURCES.txt +2 -0
  11. woltapi-0.3.0/tests/test_basket.py +229 -0
  12. {woltapi-0.2.0 → woltapi-0.3.0}/tests/test_client.py +65 -0
  13. {woltapi-0.2.0 → woltapi-0.3.0}/tests/test_order_example.py +79 -95
  14. {woltapi-0.2.0 → woltapi-0.3.0}/tests/test_selection.py +93 -0
  15. {woltapi-0.2.0 → woltapi-0.3.0}/LICENSE +0 -0
  16. {woltapi-0.2.0 → woltapi-0.3.0}/MANIFEST.in +0 -0
  17. {woltapi-0.2.0 → woltapi-0.3.0}/RELEASING.md +0 -0
  18. {woltapi-0.2.0 → woltapi-0.3.0}/examples/browse.py +0 -0
  19. {woltapi-0.2.0 → woltapi-0.3.0}/examples/check_session.py +0 -0
  20. {woltapi-0.2.0 → woltapi-0.3.0}/setup.cfg +0 -0
  21. {woltapi-0.2.0 → woltapi-0.3.0}/src/woltapi/auth.py +0 -0
  22. {woltapi-0.2.0 → woltapi-0.3.0}/src/woltapi/credentials.py +0 -0
  23. {woltapi-0.2.0 → woltapi-0.3.0}/src/woltapi/errors.py +0 -0
  24. {woltapi-0.2.0 → woltapi-0.3.0}/src/woltapi/models.py +0 -0
  25. {woltapi-0.2.0 → woltapi-0.3.0}/src/woltapi/purchase.py +0 -0
  26. {woltapi-0.2.0 → woltapi-0.3.0}/src/woltapi/services.py +0 -0
  27. {woltapi-0.2.0 → woltapi-0.3.0}/src/woltapi/transport.py +0 -0
  28. {woltapi-0.2.0 → woltapi-0.3.0}/src/woltapi.egg-info/dependency_links.txt +0 -0
  29. {woltapi-0.2.0 → woltapi-0.3.0}/src/woltapi.egg-info/requires.txt +0 -0
  30. {woltapi-0.2.0 → woltapi-0.3.0}/src/woltapi.egg-info/top_level.txt +0 -0
  31. {woltapi-0.2.0 → woltapi-0.3.0}/tests/__init__.py +0 -0
  32. {woltapi-0.2.0 → woltapi-0.3.0}/tests/test_auth.py +0 -0
  33. {woltapi-0.2.0 → woltapi-0.3.0}/tests/test_browse.py +0 -0
  34. {woltapi-0.2.0 → woltapi-0.3.0}/tests/test_check_session.py +0 -0
  35. {woltapi-0.2.0 → woltapi-0.3.0}/tests/test_purchase.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: woltapi
3
- Version: 0.2.0
3
+ Version: 0.3.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
@@ -261,6 +261,53 @@ Responses can contain personal information. Avoid printing entire responses or
261
261
  sending them to shared logs. The examples print saved addresses and card labels
262
262
  to the local terminal only. The browsing example prints only selected fields.
263
263
 
264
+ ### Baskets
265
+
266
+ A basket is your list of chosen items at one restaurant. The `Basket` class
267
+ builds that list locally, with no network requests. It reads names, option
268
+ prices, and totals from the restaurant's menu, so you do not type any prices:
269
+
270
+ ```python
271
+ from woltapi import Basket, OptionSelection, OptionValueSelection
272
+
273
+ menu = client.get_assortment(restaurant.slug)
274
+ basket = Basket(menu, "en")
275
+
276
+ basket.add_item("<item id>", count=2)
277
+ basket.add_item(
278
+ "<other item id>",
279
+ options=[
280
+ OptionSelection(
281
+ "<item option configuration id>", [OptionValueSelection("<value id>", 1)]
282
+ ),
283
+ ],
284
+ )
285
+ basket.set_count("<item id>", 1)
286
+ basket.set_options("<item id>", [])
287
+ basket.remove_item("<other item id>")
288
+
289
+ items = basket.item_selections()
290
+ ```
291
+
292
+ Item, option, and value IDs come from the assortment dictionary. For
293
+ `OptionSelection`, use the configuration ID from the catalog item's own
294
+ `options` list, not the root option ID from the assortment's top-level
295
+ `options`. Pass the result of `item_selections()` to
296
+ `client.create_selection(...)`, which validates the whole selection against the
297
+ current menu.
298
+
299
+ Wolt also stores one basket per restaurant on its servers. These are the
300
+ baskets you see in the Wolt app. The library can read and replace them:
301
+
302
+ | Call | What it does |
303
+ | --- | --- |
304
+ | `client.save_basket(selection)` | Replaces the server basket for that restaurant with your selection. This does not place an order. |
305
+ | `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
+ | `client.get_baskets_page(latitude, longitude)` | The full baskets page as a dictionary. It can contain personal data; do not log it raw. |
308
+
309
+ Deleting a server basket is not supported.
310
+
264
311
  ## Can it order food?
265
312
 
266
313
  Not as a simple, ready-to-use feature yet. There is no `order("pizza")` method.
@@ -274,23 +321,22 @@ python examples/order.py --latitude 60.17 --longitude 24.94 --query pizza \
274
321
  ```
275
322
 
276
323
  Use your own coordinates. It reads and saves your refresh token as described in
277
- [Get a token](#get-a-token), guides you through selecting an item and a saved
278
- delivery target/card, then asks before requesting a price. It never submits a
279
- purchase. Basket saving is off by default and needs a separate confirmation if
280
- enabled.
281
-
282
- The checkout example derives `category_id`, `category_ids`, and the three
283
- checkout exclusion flags from the assortment, so those fields do not need a
284
- browser context file. It only supports items in exactly one category and stops
285
- rather than guessing for zero or multiple categories. If it stops, you can
286
- supply the missing values yourself with `--context-file <path>`, a JSON object
287
- whose `checkout_fields` entries were copied from your own browser's checkout
288
- request for the same item. It may still stop if other required catalog data is
289
- missing. Run `python examples/order.py --help` for available options.
290
-
291
- The library has methods to choose items, save a basket, ask Wolt for a price,
292
- and submit a purchase. But some required inputs still need to come from your
293
- own code, including browser/device information and detailed item data.
324
+ [Get a token](#get-a-token), guides you through selecting one or more items with
325
+ their options and a saved delivery target/card, then asks before requesting a
326
+ price. Line totals come from the menu through the local basket builder; you do
327
+ not type any amounts. It never submits a purchase. Basket saving is off by
328
+ default and needs a separate confirmation if enabled.
329
+
330
+ The basket builder derives `category_id`, `category_ids`, and the three checkout
331
+ exclusion flags from the assortment. It only supports items in exactly one
332
+ category and stops rather than guessing for zero or multiple categories. It may
333
+ also stop if required catalog data is missing. Run
334
+ `python examples/order.py --help` for available options.
335
+
336
+ The library has methods to build and manage a basket, save it, ask Wolt for a
337
+ price, and submit a purchase. But some purchase inputs still need to come from
338
+ your own code, including browser/device information and a few purchase-only
339
+ item fields.
294
340
 
295
341
  Order-history and saved-address reads have worked in live checks. **Payment and
296
342
  purchase handling have not been verified with a real order.** The purchase code
@@ -243,6 +243,53 @@ Responses can contain personal information. Avoid printing entire responses or
243
243
  sending them to shared logs. The examples print saved addresses and card labels
244
244
  to the local terminal only. The browsing example prints only selected fields.
245
245
 
246
+ ### Baskets
247
+
248
+ A basket is your list of chosen items at one restaurant. The `Basket` class
249
+ builds that list locally, with no network requests. It reads names, option
250
+ prices, and totals from the restaurant's menu, so you do not type any prices:
251
+
252
+ ```python
253
+ from woltapi import Basket, OptionSelection, OptionValueSelection
254
+
255
+ menu = client.get_assortment(restaurant.slug)
256
+ basket = Basket(menu, "en")
257
+
258
+ basket.add_item("<item id>", count=2)
259
+ basket.add_item(
260
+ "<other item id>",
261
+ options=[
262
+ OptionSelection(
263
+ "<item option configuration id>", [OptionValueSelection("<value id>", 1)]
264
+ ),
265
+ ],
266
+ )
267
+ basket.set_count("<item id>", 1)
268
+ basket.set_options("<item id>", [])
269
+ basket.remove_item("<other item id>")
270
+
271
+ items = basket.item_selections()
272
+ ```
273
+
274
+ Item, option, and value IDs come from the assortment dictionary. For
275
+ `OptionSelection`, use the configuration ID from the catalog item's own
276
+ `options` list, not the root option ID from the assortment's top-level
277
+ `options`. Pass the result of `item_selections()` to
278
+ `client.create_selection(...)`, which validates the whole selection against the
279
+ current menu.
280
+
281
+ Wolt also stores one basket per restaurant on its servers. These are the
282
+ baskets you see in the Wolt app. The library can read and replace them:
283
+
284
+ | Call | What it does |
285
+ | --- | --- |
286
+ | `client.save_basket(selection)` | Replaces the server basket for that restaurant with your selection. This does not place an order. |
287
+ | `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
+ | `client.get_baskets_page(latitude, longitude)` | The full baskets page as a dictionary. It can contain personal data; do not log it raw. |
290
+
291
+ Deleting a server basket is not supported.
292
+
246
293
  ## Can it order food?
247
294
 
248
295
  Not as a simple, ready-to-use feature yet. There is no `order("pizza")` method.
@@ -256,23 +303,22 @@ python examples/order.py --latitude 60.17 --longitude 24.94 --query pizza \
256
303
  ```
257
304
 
258
305
  Use your own coordinates. It reads and saves your refresh token as described in
259
- [Get a token](#get-a-token), guides you through selecting an item and a saved
260
- delivery target/card, then asks before requesting a price. It never submits a
261
- purchase. Basket saving is off by default and needs a separate confirmation if
262
- enabled.
263
-
264
- The checkout example derives `category_id`, `category_ids`, and the three
265
- checkout exclusion flags from the assortment, so those fields do not need a
266
- browser context file. It only supports items in exactly one category and stops
267
- rather than guessing for zero or multiple categories. If it stops, you can
268
- supply the missing values yourself with `--context-file <path>`, a JSON object
269
- whose `checkout_fields` entries were copied from your own browser's checkout
270
- request for the same item. It may still stop if other required catalog data is
271
- missing. Run `python examples/order.py --help` for available options.
272
-
273
- The library has methods to choose items, save a basket, ask Wolt for a price,
274
- and submit a purchase. But some required inputs still need to come from your
275
- own code, including browser/device information and detailed item data.
306
+ [Get a token](#get-a-token), guides you through selecting one or more items with
307
+ their options and a saved delivery target/card, then asks before requesting a
308
+ price. Line totals come from the menu through the local basket builder; you do
309
+ not type any amounts. It never submits a purchase. Basket saving is off by
310
+ default and needs a separate confirmation if enabled.
311
+
312
+ The basket builder derives `category_id`, `category_ids`, and the three checkout
313
+ exclusion flags from the assortment. It only supports items in exactly one
314
+ category and stops rather than guessing for zero or multiple categories. It may
315
+ also stop if required catalog data is missing. Run
316
+ `python examples/order.py --help` for available options.
317
+
318
+ The library has methods to build and manage a basket, save it, ask Wolt for a
319
+ price, and submit a purchase. But some purchase inputs still need to come from
320
+ your own code, including browser/device information and a few purchase-only
321
+ item fields.
276
322
 
277
323
  Order-history and saved-address reads have worked in live checks. **Payment and
278
324
  purchase handling have not been verified with a real order.** The purchase code
@@ -9,15 +9,14 @@ from pathlib import Path
9
9
  from browse import TokenFileError, check_token_file, refresh_credentials, text
10
10
 
11
11
  from woltapi import (
12
+ Basket,
12
13
  DeliverySelection,
13
14
  HTTPStatusError,
14
- ItemSelection,
15
15
  OptionSelection,
16
16
  OptionValueSelection,
17
17
  VenueCheckoutContext,
18
18
  WoltApiError,
19
19
  WoltClient,
20
- derive_checkout_fields,
21
20
  )
22
21
 
23
22
 
@@ -101,7 +100,6 @@ def select_options(item, assortment, language):
101
100
  raise CheckoutInputError("Missing catalog option lists.")
102
101
  selected = []
103
102
  descriptions = []
104
- option_amount = 0
105
103
  for config in configurations:
106
104
  if not isinstance(config, dict) or config.get("prerequisite_values") != []:
107
105
  raise CheckoutInputError(
@@ -145,19 +143,13 @@ def select_options(item, assortment, language):
145
143
  f"Count for {name} (0 to {single}): ", 0, single, empty_value=0
146
144
  )
147
145
  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
146
  selections.append(OptionValueSelection(value["id"], count))
154
147
  descriptions.append(f"{count} x {name}")
155
- option_amount += price * count
156
148
  if not low <= sum(v.count for v in selections) <= high:
157
149
  raise CheckoutInputError("Selected options do not meet the catalog limits.")
158
150
  if selections:
159
151
  selected.append(OptionSelection(config["id"], selections))
160
- return selected, descriptions, option_amount
152
+ return selected, descriptions
161
153
 
162
154
 
163
155
  def checkout(client, args, context):
@@ -168,6 +160,8 @@ def checkout(client, args, context):
168
160
  raise CheckoutInputError(
169
161
  "Static venue response does not match the selected restaurant."
170
162
  )
163
+ if context and context.get("venue_id") != venue.id:
164
+ raise CheckoutInputError("Context venue_id must match the selected restaurant.")
171
165
  venue_fields = fields(
172
166
  static,
173
167
  context.get("venue", {}),
@@ -185,60 +179,32 @@ def checkout(client, args, context):
185
179
  isinstance(i, dict) for i in catalog_items
186
180
  ):
187
181
  raise CheckoutInputError("Missing assortment items.")
188
- item = choose(
189
- "menu item",
190
- catalog_items,
191
- lambda i: f"{text(i.get('name'), args.language)} | {money(i.get('price'), currency)}",
192
- )
193
- if context and (
194
- context.get("venue_id") != venue.id or context.get("item_id") != item.get("id")
195
- ):
196
- raise CheckoutInputError(
197
- "Context venue_id and item_id must match the selected restaurant and item."
182
+ basket = Basket(assortment, args.language)
183
+ selected_items = []
184
+ while True:
185
+ item = choose(
186
+ "menu item",
187
+ catalog_items,
188
+ lambda i: f"{text(i.get('name'), args.language)} | {money(i.get('price'), currency)}",
198
189
  )
199
- if item.get("restrictions") != []:
200
- raise CheckoutInputError("Restricted items are unsupported.")
201
- methods = item.get("allowed_delivery_methods")
202
- if not isinstance(methods, list) or "homedelivery" not in methods:
203
- raise CheckoutInputError("This item does not explicitly support home delivery.")
204
- item_price = item.get("price")
205
- if type(item_price) is not int:
206
- raise CheckoutInputError("Missing or invalid catalog item price.")
207
- count = number("Item quantity: ", 1)
208
- options, option_names, option_amount = select_options(
209
- item, assortment, args.language
210
- )
211
- checkout_fields = fields(
212
- derive_checkout_fields(assortment, item),
213
- context.get("checkout_fields", {}),
214
- (
215
- "category_id",
216
- "category_ids",
217
- "exclude_from_credits",
218
- "exclude_from_discounts",
219
- "exclude_from_discounts_min_basket",
220
- "alcohol_permille",
221
- "restrictions",
222
- ),
223
- "checkout_fields",
224
- )
225
- if checkout_fields["alcohol_permille"] != 0:
226
- raise CheckoutInputError("Age-restricted items are unsupported.")
227
- payment_fields = fields(
228
- item,
229
- context.get("payment_fields", {}),
230
- (
231
- "product_hierarchy_tags",
232
- "vat_percentage",
233
- "vat_percentage_decimal",
234
- ),
235
- "payment_fields",
236
- )
237
- unit_amount = item_price + option_amount
238
- print(f"Computed configured unit price: {money(unit_amount, currency)}")
239
- if input("Does this match the Wolt UI? Type YES: ").strip() != "YES":
240
- print("Cancelled before card lookup or quote.")
241
- return
190
+ if item.get("restrictions") != []:
191
+ raise CheckoutInputError("Restricted items are unsupported.")
192
+ methods = item.get("allowed_delivery_methods")
193
+ if not isinstance(methods, list) or "homedelivery" not in methods:
194
+ raise CheckoutInputError(
195
+ "This item does not explicitly support home delivery."
196
+ )
197
+ count = number("Item quantity: ", 1)
198
+ options, option_names = select_options(item, assortment, args.language)
199
+ basket.add_item(item["id"], count, options)
200
+ selected_item = basket.contents[item["id"]]
201
+ if selected_item.checkout_fields["alcohol_permille"] != 0:
202
+ raise CheckoutInputError("Age-restricted items are unsupported.")
203
+ selected_items.append((selected_item, option_names))
204
+ if input("Add another menu item? Type YES: ").strip() != "YES":
205
+ break
206
+
207
+ item_selections = basket.item_selections()
242
208
  tip = number("Courier tip in cents (0 for none): ")
243
209
  delivery = choose(
244
210
  "saved delivery target", client.list_delivery_targets(), delivery_description
@@ -257,10 +223,11 @@ def checkout(client, args, context):
257
223
  "available_methods": ["card"],
258
224
  "items": [
259
225
  {
260
- "id": item["id"],
261
- "alcohol_permille": checkout_fields["alcohol_permille"],
262
- **payment_fields,
226
+ "id": item.id,
227
+ "alcohol_permille": item.checkout_fields["alcohol_permille"],
228
+ **item.payment_fields,
263
229
  }
230
+ for item in item_selections
264
231
  ],
265
232
  }
266
233
  card = choose(
@@ -268,31 +235,24 @@ def checkout(client, args, context):
268
235
  client.get_payment_methods(card_context),
269
236
  card_description,
270
237
  )
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
238
  selection = client.create_selection(
283
239
  assortment,
284
240
  venue=VenueCheckoutContext(id=venue.id, preorder_config=None, **venue_fields),
285
241
  delivery=DeliverySelection(delivery.id, args.latitude, args.longitude),
286
242
  payment_method={"id": card.id, "type": card.type},
287
243
  courier_tip=tip,
288
- items=[selected_item],
289
- )
290
- print(
291
- f"\n{count} x {selected_item.basket_name} at {text(venue.title or venue.slug)}"
244
+ items=item_selections,
292
245
  )
293
- for name in option_names:
294
- print(f" Option: {name}")
295
- print(f"Configured unit price: {money(unit_amount, currency)}")
246
+ for selected_item, option_names in selected_items:
247
+ print(
248
+ f"\n{selected_item.count} x {selected_item.basket_name} "
249
+ f"at {text(venue.title or venue.slug)}"
250
+ )
251
+ for name in option_names:
252
+ print(f" Option: {name}")
253
+ print(
254
+ f"Catalog-derived line total: {money(selected_item.basket_price, currency)}"
255
+ )
296
256
  print(
297
257
  f"Delivery: {delivery_description(delivery)} | Card: {card_description(card)}"
298
258
  )
@@ -360,7 +320,7 @@ def main():
360
320
  parser.add_argument(
361
321
  "--context-file",
362
322
  type=Path,
363
- help="Optional current browser metadata for fields missing from the catalog",
323
+ help="Optional current browser metadata for venue fields missing from static data",
364
324
  )
365
325
  parser.add_argument(
366
326
  "--save-basket",
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "woltapi"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "A small synchronous client for observed Wolt ordering flows."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,7 @@
1
1
  """Synchronous selection and explicit purchase client for observed Wolt hosts."""
2
2
 
3
3
  from .auth import RefreshTokenCredentials
4
+ from .basket import Basket
4
5
  from .client import WoltClient
5
6
  from .credentials import SessionCredentials
6
7
  from .errors import (
@@ -50,6 +51,7 @@ from .transport import WoltTransport
50
51
  __all__ = [
51
52
  "DeliveryTarget",
52
53
  "DeliverySelection",
54
+ "Basket",
53
55
  "DuplicatePurchaseAttempt",
54
56
  "HTTPStatusError",
55
57
  "ItemSelection",