woltapi 0.4.0__tar.gz → 0.4.1__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 (37) hide show
  1. {woltapi-0.4.0/src/woltapi.egg-info → woltapi-0.4.1}/PKG-INFO +1 -1
  2. woltapi-0.4.1/examples/verify_saved_basket_rebuild.py +108 -0
  3. {woltapi-0.4.0 → woltapi-0.4.1}/pyproject.toml +1 -1
  4. {woltapi-0.4.0 → woltapi-0.4.1}/src/woltapi/basket.py +10 -1
  5. {woltapi-0.4.0 → woltapi-0.4.1}/src/woltapi/purchase.py +1 -0
  6. {woltapi-0.4.0 → woltapi-0.4.1/src/woltapi.egg-info}/PKG-INFO +1 -1
  7. {woltapi-0.4.0 → woltapi-0.4.1}/src/woltapi.egg-info/SOURCES.txt +3 -1
  8. {woltapi-0.4.0 → woltapi-0.4.1}/tests/test_basket.py +46 -1
  9. woltapi-0.4.1/tests/test_verify_saved_basket_rebuild_example.py +103 -0
  10. {woltapi-0.4.0 → woltapi-0.4.1}/LICENSE +0 -0
  11. {woltapi-0.4.0 → woltapi-0.4.1}/MANIFEST.in +0 -0
  12. {woltapi-0.4.0 → woltapi-0.4.1}/README.md +0 -0
  13. {woltapi-0.4.0 → woltapi-0.4.1}/RELEASING.md +0 -0
  14. {woltapi-0.4.0 → woltapi-0.4.1}/examples/browse.py +0 -0
  15. {woltapi-0.4.0 → woltapi-0.4.1}/examples/check_session.py +0 -0
  16. {woltapi-0.4.0 → woltapi-0.4.1}/examples/order.py +0 -0
  17. {woltapi-0.4.0 → woltapi-0.4.1}/setup.cfg +0 -0
  18. {woltapi-0.4.0 → woltapi-0.4.1}/src/woltapi/__init__.py +0 -0
  19. {woltapi-0.4.0 → woltapi-0.4.1}/src/woltapi/auth.py +0 -0
  20. {woltapi-0.4.0 → woltapi-0.4.1}/src/woltapi/client.py +0 -0
  21. {woltapi-0.4.0 → woltapi-0.4.1}/src/woltapi/credentials.py +0 -0
  22. {woltapi-0.4.0 → woltapi-0.4.1}/src/woltapi/errors.py +0 -0
  23. {woltapi-0.4.0 → woltapi-0.4.1}/src/woltapi/models.py +0 -0
  24. {woltapi-0.4.0 → woltapi-0.4.1}/src/woltapi/selection.py +0 -0
  25. {woltapi-0.4.0 → woltapi-0.4.1}/src/woltapi/services.py +0 -0
  26. {woltapi-0.4.0 → woltapi-0.4.1}/src/woltapi/transport.py +0 -0
  27. {woltapi-0.4.0 → woltapi-0.4.1}/src/woltapi.egg-info/dependency_links.txt +0 -0
  28. {woltapi-0.4.0 → woltapi-0.4.1}/src/woltapi.egg-info/requires.txt +0 -0
  29. {woltapi-0.4.0 → woltapi-0.4.1}/src/woltapi.egg-info/top_level.txt +0 -0
  30. {woltapi-0.4.0 → woltapi-0.4.1}/tests/__init__.py +0 -0
  31. {woltapi-0.4.0 → woltapi-0.4.1}/tests/test_auth.py +0 -0
  32. {woltapi-0.4.0 → woltapi-0.4.1}/tests/test_browse.py +0 -0
  33. {woltapi-0.4.0 → woltapi-0.4.1}/tests/test_check_session.py +0 -0
  34. {woltapi-0.4.0 → woltapi-0.4.1}/tests/test_client.py +0 -0
  35. {woltapi-0.4.0 → woltapi-0.4.1}/tests/test_order_example.py +0 -0
  36. {woltapi-0.4.0 → woltapi-0.4.1}/tests/test_purchase.py +0 -0
  37. {woltapi-0.4.0 → woltapi-0.4.1}/tests/test_selection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: woltapi
3
- Version: 0.4.0
3
+ Version: 0.4.1
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
@@ -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())
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "woltapi"
7
- version = "0.4.0"
7
+ version = "0.4.1"
8
8
  description = "A small synchronous client for observed Wolt ordering flows."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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
- raise SelectionError("The selected item has no name for the basket language.")
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(
@@ -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] = []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: woltapi
3
- Version: 0.4.0
3
+ Version: 0.4.1
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
@@ -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": []})
@@ -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