walla-cli 0.1.9__tar.gz → 0.1.11__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 (116) hide show
  1. {walla_cli-0.1.9 → walla_cli-0.1.11}/PKG-INFO +4 -3
  2. {walla_cli-0.1.9 → walla_cli-0.1.11}/README.md +3 -2
  3. {walla_cli-0.1.9 → walla_cli-0.1.11}/docs/WIRE.md +9 -1
  4. walla_cli-0.1.11/docs/assets/walla-demo-preview.gif +0 -0
  5. walla_cli-0.1.11/fixtures/categories.json +59 -0
  6. walla_cli-0.1.11/fixtures/sell_suggest_draft.json +23 -0
  7. {walla_cli-0.1.9 → walla_cli-0.1.11}/pyproject.toml +1 -1
  8. {walla_cli-0.1.9 → walla_cli-0.1.11}/skills/walla-sell/SKILL.md +10 -8
  9. walla_cli-0.1.11/tests/test_categories_find.py +58 -0
  10. {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_live.py +2 -1
  11. {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_parsers.py +2 -0
  12. {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_sell.py +14 -13
  13. walla_cli-0.1.11/tests/test_suggest_watch.py +101 -0
  14. {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_wire_contracts.py +3 -2
  15. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/__init__.py +1 -1
  16. walla_cli-0.1.11/walla/account/desk_watch.py +36 -0
  17. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/account/sell.py +25 -58
  18. walla_cli-0.1.11/walla/account/sell_steps.py +82 -0
  19. walla_cli-0.1.11/walla/account/sell_suggest.py +77 -0
  20. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/account/sell_wire.py +5 -2
  21. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/cli/cmd_desk.py +12 -2
  22. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/cli/cmd_look.py +27 -1
  23. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/cli/cmd_sell.py +66 -14
  24. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/core/instruct.py +6 -1
  25. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/mcp/server.py +36 -6
  26. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/models/profile.py +1 -0
  27. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/search/__init__.py +12 -0
  28. walla_cli-0.1.11/walla/search/category_find.py +98 -0
  29. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/search/parse.py +17 -13
  30. walla_cli-0.1.11/walla/search/pick_category.py +47 -0
  31. walla_cli-0.1.9/fixtures/categories.json +0 -49
  32. {walla_cli-0.1.9 → walla_cli-0.1.11}/.cursor/rules/quality.mdc +0 -0
  33. {walla_cli-0.1.9 → walla_cli-0.1.11}/.github/workflows/ci.yml +0 -0
  34. {walla_cli-0.1.9 → walla_cli-0.1.11}/.gitignore +0 -0
  35. {walla_cli-0.1.9 → walla_cli-0.1.11}/AGENTS.md +0 -0
  36. {walla_cli-0.1.9 → walla_cli-0.1.11}/LICENSE +0 -0
  37. {walla_cli-0.1.9 → walla_cli-0.1.11}/docs/assets/walla-demo-poster.jpg +0 -0
  38. {walla_cli-0.1.9 → walla_cli-0.1.11}/docs/assets/walla-demo.mp4 +0 -0
  39. {walla_cli-0.1.9 → walla_cli-0.1.11}/docs/assets/walla-github-banner.jpg +0 -0
  40. {walla_cli-0.1.9 → walla_cli-0.1.11}/docs/code_quality.md +0 -0
  41. {walla_cli-0.1.9 → walla_cli-0.1.11}/docs/data_engineering_standards.md +0 -0
  42. {walla_cli-0.1.9 → walla_cli-0.1.11}/docs/mcp.md +0 -0
  43. {walla_cli-0.1.9 → walla_cli-0.1.11}/docs/objective_function.md +0 -0
  44. {walla_cli-0.1.9 → walla_cli-0.1.11}/docs/rate_limit_analysis.md +0 -0
  45. {walla_cli-0.1.9 → walla_cli-0.1.11}/fixtures/item_detail.json +0 -0
  46. {walla_cli-0.1.9 → walla_cli-0.1.11}/fixtures/offer_buyer_request.json +0 -0
  47. {walla_cli-0.1.9 → walla_cli-0.1.11}/fixtures/search_bicicleta.json +0 -0
  48. {walla_cli-0.1.9 → walla_cli-0.1.11}/fixtures/sell_item_request.json +0 -0
  49. {walla_cli-0.1.9 → walla_cli-0.1.11}/fixtures/wire_contracts.json +0 -0
  50. {walla_cli-0.1.9 → walla_cli-0.1.11}/scripts/check_code_quality.py +0 -0
  51. {walla_cli-0.1.9 → walla_cli-0.1.11}/scripts/dogfood_run.py +0 -0
  52. {walla_cli-0.1.9 → walla_cli-0.1.11}/scripts/probe_rate.py +0 -0
  53. {walla_cli-0.1.9 → walla_cli-0.1.11}/skills/walla/SKILL.md +0 -0
  54. {walla_cli-0.1.9 → walla_cli-0.1.11}/skills/walla-buy/SKILL.md +0 -0
  55. {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_account.py +0 -0
  56. {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_account_more.py +0 -0
  57. {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_catch.py +0 -0
  58. {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_chat_open.py +0 -0
  59. {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_cli_json.py +0 -0
  60. {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_core.py +0 -0
  61. {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_desk.py +0 -0
  62. {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_edges.py +0 -0
  63. {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_export.py +0 -0
  64. {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_http_more.py +0 -0
  65. {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_negotiate.py +0 -0
  66. {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_polite.py +0 -0
  67. {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_regressions.py +0 -0
  68. {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_search_api.py +0 -0
  69. {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_session_store.py +0 -0
  70. {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_shortlist.py +0 -0
  71. {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_verdict.py +0 -0
  72. {walla_cli-0.1.9 → walla_cli-0.1.11}/uv.lock +0 -0
  73. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/account/__init__.py +0 -0
  74. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/account/actions.py +0 -0
  75. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/account/chat.py +0 -0
  76. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/account/cookies.py +0 -0
  77. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/account/desk.py +0 -0
  78. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/account/inbox.py +0 -0
  79. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/account/login.py +0 -0
  80. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/account/offer_wire.py +0 -0
  81. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/account/prompt.py +0 -0
  82. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/account/session_store.py +0 -0
  83. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/cli/__init__.py +0 -0
  84. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/cli/banner.py +0 -0
  85. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/cli/catch.py +0 -0
  86. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/cli/cmd_account.py +0 -0
  87. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/cli/cmd_ops.py +0 -0
  88. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/cli/console.py +0 -0
  89. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/cli/main.py +0 -0
  90. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/core/__init__.py +0 -0
  91. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/core/envelope.py +0 -0
  92. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/core/errors.py +0 -0
  93. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/core/exceptions.py +0 -0
  94. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/core/human_fix.py +0 -0
  95. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/core/path.py +0 -0
  96. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/http/__init__.py +0 -0
  97. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/http/client.py +0 -0
  98. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/http/headers.py +0 -0
  99. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/http/polite.py +0 -0
  100. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/hunter/__init__.py +0 -0
  101. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/hunter/converge.py +0 -0
  102. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/hunter/export.py +0 -0
  103. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/hunter/export_pdf.py +0 -0
  104. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/hunter/negotiate.py +0 -0
  105. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/hunter/profile_store.py +0 -0
  106. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/hunter/pursuits.py +0 -0
  107. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/hunter/shortlist.py +0 -0
  108. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/hunter/stack.py +0 -0
  109. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/hunter/verdict.py +0 -0
  110. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/hunter/watches.py +0 -0
  111. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/mcp/__init__.py +0 -0
  112. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/mcp/_entry.py +0 -0
  113. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/models/__init__.py +0 -0
  114. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/models/account.py +0 -0
  115. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/models/listing.py +0 -0
  116. {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/search/api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: walla-cli
3
- Version: 0.1.9
3
+ Version: 0.1.11
4
4
  Summary: Wallapop.es CLI - look · offer · talk · sell for humans and agents
5
5
  Project-URL: Homepage, https://github.com/berendgort/wallapop-cli
6
6
  Project-URL: Repository, https://github.com/berendgort/wallapop-cli
@@ -55,9 +55,10 @@ Description-Content-Type: text/markdown
55
55
  Dr. Berend Gort · www.berendgort.dev
56
56
  ```
57
57
 
58
- ![walla demo](docs/assets/walla-demo.mp4)
58
+ [![walla sell demo](docs/assets/walla-demo-preview.gif)](https://github.com/berendgort/wallapop-cli/releases/download/demo/walla-demo.mp4)
59
59
 
60
- Demo music: [Funkee Monkeee](https://mixkit.co/free-stock-music/funkee-monkeee-1140/) by Michael Ramir C.
60
+ ▶ [Play full demo with sound](https://github.com/berendgort/wallapop-cli/releases/download/demo/walla-demo.mp4)
61
+ · Demo music: [Funkee Monkeee](https://mixkit.co/free-stock-music/funkee-monkeee-1140/) by Michael Ramir C.
61
62
  ([Mixkit Stock Music Free License](https://mixkit.co/license/#musicFree)).
62
63
 
63
64
  **walla** turns Claude, GPT, or Cursor into a Wallapop helper that actually
@@ -11,9 +11,10 @@
11
11
  Dr. Berend Gort · www.berendgort.dev
12
12
  ```
13
13
 
14
- ![walla demo](docs/assets/walla-demo.mp4)
14
+ [![walla sell demo](docs/assets/walla-demo-preview.gif)](https://github.com/berendgort/wallapop-cli/releases/download/demo/walla-demo.mp4)
15
15
 
16
- Demo music: [Funkee Monkeee](https://mixkit.co/free-stock-music/funkee-monkeee-1140/) by Michael Ramir C.
16
+ ▶ [Play full demo with sound](https://github.com/berendgort/wallapop-cli/releases/download/demo/walla-demo.mp4)
17
+ · Demo music: [Funkee Monkeee](https://mixkit.co/free-stock-music/funkee-monkeee-1140/) by Michael Ramir C.
17
18
  ([Mixkit Stock Music Free License](https://mixkit.co/license/#musicFree)).
18
19
 
19
20
  **walla** turns Claude, GPT, or Cursor into a Wallapop helper that actually
@@ -29,7 +29,15 @@ Public URL: `https://es.wallapop.com/item/<web_slug>`.
29
29
 
30
30
  ## Categories
31
31
 
32
- `GET https://api.wallapop.com/api/v3/categories` — top-level tree.
32
+ `GET https://api.wallapop.com/api/v3/categories` — nested tree
33
+ (`subcategories[]`). CLI: `walla categories --find escritorio --json` returns
34
+ `leaf_id` + `root_id`. Sell accepts `--category <leaf-or-name>`; `--root` is
35
+ optional.
36
+
37
+ After `current_step=photo`, the steps `draft` may include `category_leaf_id`
38
+ (title+photo hint). `GET /api/v3/suggested-item-data/{upload_id}` often returns
39
+ empty HTTP 200; still poll before `loading`. CLI: `walla sell … --title … --suggest`.
40
+ `walla desk --watch` polls the inbox until `stack.best` converges.
33
41
 
34
42
  ## Auth
35
43
 
@@ -0,0 +1,59 @@
1
+ {
2
+ "categories": [
3
+ {
4
+ "id": 100,
5
+ "name": "Cars",
6
+ "path": "",
7
+ "vertical_id": "cars",
8
+ "icon": "car",
9
+ "subcategories": [],
10
+ "category_leaf_selection_mandatory": false
11
+ },
12
+ {
13
+ "id": 12467,
14
+ "name": "Hogar y jardín",
15
+ "path": "",
16
+ "vertical_id": "consumer_goods",
17
+ "icon": "home",
18
+ "subcategories": [
19
+ {
20
+ "id": 10125,
21
+ "name": "Muebles y organización",
22
+ "parent_id": 12467,
23
+ "subcategories": [
24
+ {
25
+ "id": 24207,
26
+ "name": "Mesas y mesitas",
27
+ "parent_id": 10125,
28
+ "subcategories": [
29
+ {
30
+ "id": 24208,
31
+ "name": "Escritorios",
32
+ "parent_id": 24207,
33
+ "subcategories": []
34
+ }
35
+ ]
36
+ }
37
+ ]
38
+ }
39
+ ],
40
+ "category_leaf_selection_mandatory": false
41
+ },
42
+ {
43
+ "id": 12579,
44
+ "name": "Deporte y ocio",
45
+ "path": "",
46
+ "vertical_id": "consumer_goods",
47
+ "icon": "ball",
48
+ "subcategories": [
49
+ {
50
+ "id": 10105,
51
+ "name": "Surf y bodyboard",
52
+ "parent_id": 12579,
53
+ "subcategories": []
54
+ }
55
+ ],
56
+ "category_leaf_selection_mandatory": false
57
+ }
58
+ ]
59
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "schema_version": 1,
3
+ "note": "After photo step, Wallapop may inject category_leaf_id from title+photos. suggested-item-data often returns empty HTTP 200.",
4
+ "after_photo": {
5
+ "step": {"id": "category", "components": []},
6
+ "draft": {
7
+ "title": "Escritorio madera oscura patas cromadas",
8
+ "category_leaf_id": "24208"
9
+ }
10
+ },
11
+ "after_loading": {
12
+ "step": {"id": "listing", "components": []},
13
+ "draft": {
14
+ "title": "Escritorio madera oscura patas cromadas",
15
+ "category_leaf_id": "24208",
16
+ "root_category_id": "12467"
17
+ }
18
+ },
19
+ "suggested_item_data": {
20
+ "http_status": 200,
21
+ "body": null
22
+ }
23
+ }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "walla-cli"
3
- version = "0.1.9"
3
+ version = "0.1.11"
4
4
  description = "Wallapop.es CLI - look · offer · talk · sell for humans and agents"
5
5
  authors = [{ name = "Dr. Berend Gort", email = "berend.gort@gmail.com" }]
6
6
  readme = "README.md"
@@ -13,29 +13,31 @@ license: MIT
13
13
  Do not ask the human to type buyer replies. Desk sends those.
14
14
 
15
15
  ```bash
16
- walla sell ./shot1.jpg ./shot2.jpg --json
16
+ walla categories --find escritorio --json
17
+ walla sell ./shot1.jpg ./shot2.jpg --title "Escritorio madera" --suggest --json
17
18
  ```
18
19
 
19
- Ask every question in `data.questions`, then publish once:
20
+ `--suggest` (or draft with `--title` and no `--category`) asks Wallapop to prefill
21
+ `category_leaf_id` from title+photos. Ask remaining `data.questions`, then:
20
22
 
21
23
  ```bash
22
24
  walla sell ./shot1.jpg ./shot2.jpg \
23
25
  --title "…" --desc "…" --eur 40 \
24
- --category 10105 --root 12579 --condition good \
26
+ --category escritorio --condition good \
25
27
  --floor 35 --yes --json
26
28
  ```
27
29
 
28
- `--floor` is the lowest EUR desk may accept. Omit it to hold the asking price.
29
- Category ids come from `walla categories --json`.
30
+ `--category` accepts a leaf id **or** name tokens. `--root` is optional.
31
+ `--floor` is the lowest EUR desk may accept.
30
32
 
31
33
  Then:
32
34
 
33
35
  ```bash
34
- walla desk --json
36
+ walla desk --watch --seconds 45 --rounds 12 --json
35
37
  ```
36
38
 
37
- Desk reads the inbox once and answers buyers. Repeat desk until `stack.best`
38
- is a sell row. Show the title, agreed EUR, `stack.why`, and the item link.
39
+ Desk answers buyers until `stack.best` converges or rounds end. Show the title,
40
+ agreed EUR, `stack.why`, and the item link.
39
41
 
40
42
  The human accepts the sale in the Wallapop app. `walla unsell <id> --yes` deletes
41
43
  a listing. Never mark the item sold from walla.
@@ -0,0 +1,58 @@
1
+ """Category tree find / root resolve."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from walla.search.category_find import find_categories, flatten_categories, resolve_root_id
11
+ from walla.search.parse import parse_categories
12
+ from walla.search.pick_category import pick_sell_category
13
+
14
+ FIX = Path(__file__).resolve().parents[1] / "fixtures"
15
+
16
+
17
+ def _roots():
18
+ raw = json.loads((FIX / "categories.json").read_text(encoding="utf-8"))
19
+ return parse_categories(raw)
20
+
21
+
22
+ def test_parse_nested_categories() -> None:
23
+ roots = _roots()
24
+ hogar = next(c for c in roots if c.id == 12467)
25
+ assert hogar.subcategories
26
+ assert flatten_categories(roots)
27
+ assert resolve_root_id(roots, "24208") == "12467"
28
+ assert resolve_root_id(roots, "10105") == "12579"
29
+
30
+
31
+ def test_find_escritorio() -> None:
32
+ hits = find_categories(_roots(), "escritorio")
33
+ assert hits
34
+ assert hits[0].leaf_id == "24208"
35
+ assert hits[0].root_id == "12467"
36
+ assert hits[0].is_leaf
37
+
38
+
39
+ def test_pick_by_name_and_id() -> None:
40
+ roots = _roots()
41
+ by_name = pick_sell_category(roots, category="escritorio")
42
+ assert by_name.leaf_id == "24208"
43
+ assert by_name.root_id == "12467"
44
+ by_id = pick_sell_category(roots, category="24208")
45
+ assert by_id.leaf_id == "24208"
46
+ assert by_id.root_id == "12467"
47
+ forced = pick_sell_category(roots, category="10105", root="12579")
48
+ assert forced.root_id == "12579"
49
+
50
+
51
+ def test_find_empty_and_unknown() -> None:
52
+ with pytest.raises(ValueError, match="empty"):
53
+ find_categories(_roots(), " ")
54
+ assert find_categories(_roots(), "zzzz-no-such") == []
55
+ with pytest.raises(ValueError, match="unknown"):
56
+ resolve_root_id(_roots(), "999999")
57
+ with pytest.raises(ValueError, match="no category matches"):
58
+ pick_sell_category(_roots(), category="zzzz-no-such")
@@ -11,7 +11,8 @@ import pytest
11
11
  from walla.account.chat import open_conversation
12
12
  from walla.account.inbox import list_conversations_raw
13
13
  from walla.account.login import ensure_access_token, whoami
14
- from walla.account.sell import _step, delete_listing, publish_listing, upload_pictures
14
+ from walla.account.sell import delete_listing, publish_listing, upload_pictures
15
+ from walla.account.sell_steps import post_step as _step
15
16
  from walla.account.session_store import load_session
16
17
  from walla.http.client import HttpClient
17
18
  from walla.http.polite import reset_polite
@@ -36,3 +36,5 @@ def test_parse_categories_fixture() -> None:
36
36
  assert len(cats) >= 1
37
37
  assert cats[0].id
38
38
  assert cats[0].name
39
+ hogar = next(c for c in cats if c.id == 12467)
40
+ assert any(s.name == "Muebles y organización" for s in hogar.subcategories)
@@ -119,23 +119,23 @@ def test_publish_listing_mocked(tmp_path: Path) -> None:
119
119
  photo.write_bytes(b"\xff\xd8\xff\xd9")
120
120
  fake_http = MagicMock()
121
121
  fake_http.post.side_effect = [
122
- {"step": {"id": "title"}},
123
- {"step": {"id": "photo", "picture_upload": {"path": "/api/v3/upload/x/pictures"}}},
124
- {"step": {"id": "category"}},
122
+ {"step": {"id": "title"}, "draft": {}},
123
+ {"step": {"id": "photo"}, "draft": {"title": "Test item"}},
125
124
  {
126
- "step": {
127
- "id": "loading",
128
- "poll_before_next_step": {"path": "/api/v3/suggested-item-data/x"},
129
- }
125
+ "step": {"id": "category"},
126
+ "draft": {"title": "Test item", "category_leaf_id": "10105"},
130
127
  },
131
- {"step": {"id": "listing"}},
128
+ {"step": {"id": "loading"}, "draft": {"title": "Test item"}},
129
+ {"step": {"id": "listing"}, "draft": {"title": "Test item"}},
132
130
  ]
133
131
  fake_http.get.return_value = {}
134
132
  listing = MagicMock()
135
133
  listing.url = "https://es.wallapop.com/item/test-item-99"
136
134
  listing.web_slug = "test-item-99"
137
135
  with (
138
- patch("walla.account.sell._auth_client", return_value=fake_http),
136
+ patch("walla.account.sell.auth_client", return_value=fake_http),
137
+ patch("walla.account.sell.post_step", side_effect=fake_http.post.side_effect),
138
+ patch("walla.account.sell.poll_suggested", return_value=None),
139
139
  patch("walla.account.sell.upload_pictures", return_value=1),
140
140
  patch(
141
141
  "walla.account.sell.create_listing",
@@ -156,7 +156,6 @@ def test_publish_listing_mocked(tmp_path: Path) -> None:
156
156
  assert out["id"] == "abc123"
157
157
  assert out["photos"] == 1
158
158
  assert out["url"] == "https://es.wallapop.com/item/test-item-99"
159
- assert fake_http.post.call_count == 5
160
159
 
161
160
 
162
161
  def test_create_listing_multipart(tmp_path: Path) -> None:
@@ -179,7 +178,7 @@ def test_create_listing_multipart(tmp_path: Path) -> None:
179
178
  fake_session = MagicMock()
180
179
  fake_session.request.return_value = fake_resp
181
180
  with (
182
- patch("walla.account.sell._auth_headers", return_value={"Authorization": "Bearer x"}),
181
+ patch("walla.account.sell.auth_headers", return_value={"Authorization": "Bearer x"}),
183
182
  patch("walla.account.sell.requests.Session", return_value=fake_session),
184
183
  patch("walla.account.sell.CurlMime") as mime_cls,
185
184
  patch("walla.account.sell.wait_turn"),
@@ -227,14 +226,16 @@ def test_publish_listing_two_photos(tmp_path: Path) -> None:
227
226
  listing.url = "https://es.wallapop.com/item/test-item-99"
228
227
  listing.web_slug = "test-item-99"
229
228
  with (
230
- patch("walla.account.sell._auth_client", return_value=fake_http),
229
+ patch("walla.account.sell.auth_client", return_value=fake_http),
230
+ patch("walla.account.sell.post_step", side_effect=fake_http.post.side_effect),
231
+ patch("walla.account.sell.poll_suggested", return_value=None),
231
232
  patch("walla.account.sell.upload_pictures", return_value=2),
232
233
  patch(
233
234
  "walla.account.sell.create_listing",
234
235
  return_value={"id": "abc123", "flags": {}},
235
236
  ),
236
237
  patch("walla.account.sell.get_item", return_value=listing),
237
- patch("walla.account.sell._auth_headers", return_value={}),
238
+ patch("walla.account.sell.auth_headers", return_value={}),
238
239
  patch("walla.account.sell.requests.Session", return_value=fake_session),
239
240
  patch("walla.account.sell.CurlMime") as mime_cls,
240
241
  patch("walla.account.sell.wait_turn"),
@@ -0,0 +1,101 @@
1
+ """Suggest draft parse + desk watch stop conditions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from unittest.mock import MagicMock, patch
8
+
9
+ from walla.account.desk_watch import run_desk_watch
10
+ from walla.account.sell_steps import parse_steps_draft
11
+
12
+ FIX = Path(__file__).resolve().parents[1] / "fixtures"
13
+
14
+
15
+ def test_parse_steps_draft_fixture() -> None:
16
+ raw = json.loads((FIX / "sell_suggest_draft.json").read_text(encoding="utf-8"))
17
+ after = parse_steps_draft(raw["after_photo"])
18
+ assert after["category_leaf_id"] == "24208"
19
+ assert after["title"].startswith("Escritorio")
20
+ loaded = parse_steps_draft(raw["after_loading"])
21
+ assert loaded["root_category_id"] == "12467"
22
+
23
+
24
+ def test_desk_watch_stops_on_converged() -> None:
25
+ converged = {
26
+ "replies": [],
27
+ "stack": {"best": {"status": "converged", "title": "X", "agreed_eur": 40}},
28
+ "close_in_app": True,
29
+ }
30
+ with patch("walla.account.desk_watch.run_desk", return_value=converged) as desk:
31
+ with patch("walla.account.desk_watch.time.sleep") as slept:
32
+ out = run_desk_watch(seconds=5, rounds=5)
33
+ assert out["watch_stopped"] == "converged"
34
+ assert out["watch_round"] == 1
35
+ desk.assert_called_once()
36
+ slept.assert_not_called()
37
+
38
+
39
+ def test_desk_watch_max_rounds() -> None:
40
+ waiting = {
41
+ "replies": [],
42
+ "stack": {"best": None},
43
+ "close_in_app": True,
44
+ }
45
+ with patch("walla.account.desk_watch.run_desk", return_value=waiting) as desk:
46
+ with patch("walla.account.desk_watch.time.sleep") as slept:
47
+ out = run_desk_watch(seconds=5, rounds=3)
48
+ assert out["watch_stopped"] == "max_rounds"
49
+ assert out["watch_round"] == 3
50
+ assert desk.call_count == 3
51
+ assert slept.call_count == 2
52
+
53
+
54
+ def test_suggest_from_photos_mocked(tmp_path: Path) -> None:
55
+ from walla.account.sell_suggest import suggest_from_photos
56
+
57
+ photo = tmp_path / "a.jpg"
58
+ photo.write_bytes(b"\xff\xd8\xff\xd9")
59
+ steps = [
60
+ {"step": {"id": "title"}, "draft": {}},
61
+ {"step": {"id": "photo"}, "draft": {"title": "Escritorio madera"}},
62
+ {
63
+ "step": {"id": "category"},
64
+ "draft": {"title": "Escritorio madera", "category_leaf_id": "24208"},
65
+ },
66
+ {
67
+ "step": {"id": "loading"},
68
+ "draft": {
69
+ "title": "Escritorio madera",
70
+ "category_leaf_id": "24208",
71
+ "root_category_id": "12467",
72
+ },
73
+ },
74
+ {
75
+ "step": {"id": "listing"},
76
+ "draft": {
77
+ "title": "Escritorio madera",
78
+ "category_leaf_id": "24208",
79
+ "root_category_id": "12467",
80
+ },
81
+ },
82
+ ]
83
+ fake = MagicMock()
84
+ with (
85
+ patch("walla.account.sell_suggest.auth_client", return_value=fake),
86
+ patch("walla.account.sell_suggest.post_step", side_effect=steps),
87
+ patch("walla.account.sell_suggest.upload_pictures", return_value=1),
88
+ patch("walla.account.sell_suggest.poll_suggested", return_value=None),
89
+ patch(
90
+ "walla.account.sell_suggest.get_categories",
91
+ return_value=__import__(
92
+ "walla.search.parse", fromlist=["parse_categories"]
93
+ ).parse_categories(
94
+ json.loads((FIX / "categories.json").read_text(encoding="utf-8"))
95
+ ),
96
+ ),
97
+ ):
98
+ out = suggest_from_photos([photo], title="Escritorio madera")
99
+ assert out["category_leaf_id"] == "24208"
100
+ assert out["root_category_id"] == "12467"
101
+ assert "Escritorios" in (out.get("path") or "")
@@ -250,8 +250,9 @@ def test_every_contract_is_sent(
250
250
  expires_at=9_999_999_999,
251
251
  )
252
252
  monkeypatch.setattr("walla.account.login.ensure_access_token", lambda: sess)
253
- monkeypatch.setattr("walla.account.sell.ensure_access_token", lambda: sess)
254
- monkeypatch.setattr("walla.account.sell._auth_client", lambda: rec)
253
+ monkeypatch.setattr("walla.account.sell_steps.ensure_access_token", lambda: sess)
254
+ monkeypatch.setattr("walla.account.sell.auth_client", lambda: rec)
255
+ monkeypatch.setattr("walla.account.sell_steps.auth_client", lambda: rec)
255
256
  monkeypatch.setattr("walla.account.chat.ensure_access_token", _must_not_mint)
256
257
  monkeypatch.setattr(
257
258
  "walla.account.actions.load_profile",
@@ -4,4 +4,4 @@ from __future__ import annotations
4
4
 
5
5
  __all__ = ("__version__",)
6
6
 
7
- __version__ = "0.1.9"
7
+ __version__ = "0.1.11"
@@ -0,0 +1,36 @@
1
+ """Poll the inbox until a deal converges or rounds run out."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ from walla.account.desk import run_desk
9
+ from walla.http.client import HttpClient
10
+
11
+ __all__ = ("run_desk_watch",)
12
+
13
+
14
+ def run_desk_watch(
15
+ *,
16
+ seconds: float = 45.0,
17
+ rounds: int = 12,
18
+ client: HttpClient | None = None,
19
+ ) -> dict[str, Any]:
20
+ """Repeat run_desk until stack.best is converged or max rounds."""
21
+ if seconds < 5:
22
+ raise ValueError("--seconds must be >= 5")
23
+ if rounds < 1 or rounds > 60:
24
+ raise ValueError("--rounds must be 1..60")
25
+ last: dict[str, Any] = {}
26
+ for i in range(rounds):
27
+ last = run_desk(client=client)
28
+ last["watch_round"] = i + 1
29
+ best = (last.get("stack") or {}).get("best")
30
+ if isinstance(best, dict) and best.get("status") == "converged":
31
+ last["watch_stopped"] = "converged"
32
+ return last
33
+ if i + 1 < rounds:
34
+ time.sleep(float(seconds))
35
+ last["watch_stopped"] = "max_rounds"
36
+ return last
@@ -9,7 +9,13 @@ from typing import Any
9
9
 
10
10
  from curl_cffi import CurlMime, requests
11
11
 
12
- from walla.account.login import ensure_access_token
12
+ from walla.account.sell_steps import (
13
+ auth_client,
14
+ auth_headers,
15
+ parse_steps_draft,
16
+ poll_suggested,
17
+ post_step,
18
+ )
13
19
  from walla.account.sell_wire import build_item_body
14
20
  from walla.core.exceptions import (
15
21
  WallaHTTPError,
@@ -18,7 +24,7 @@ from walla.core.exceptions import (
18
24
  WallaUnsupportedError,
19
25
  )
20
26
  from walla.http.client import HttpClient
21
- from walla.http.headers import API_BASE, WEB_BASE, default_headers
27
+ from walla.http.headers import API_BASE, WEB_BASE
22
28
  from walla.http.polite import wait_turn
23
29
  from walla.search.api import get_item
24
30
  from walla.search.parse import item_url
@@ -39,42 +45,11 @@ _IMAGE_TYPES = {
39
45
  }
40
46
 
41
47
 
42
- def _auth_client() -> HttpClient:
43
- sess = ensure_access_token()
44
- return HttpClient(access_token=sess.access_token, device_id=sess.device_id)
45
-
46
-
47
- def _auth_headers() -> dict[str, str]:
48
- sess = ensure_access_token()
49
- headers = default_headers(device_id=sess.device_id)
50
- headers["Authorization"] = f"Bearer {sess.access_token}"
51
- return headers
52
-
53
-
54
- def _step(
55
- http: HttpClient,
56
- upload_id: str,
57
- *,
58
- current: str | None,
59
- draft: dict[str, Any],
60
- ) -> dict[str, Any]:
61
- body: dict[str, Any] = {
62
- "mode": {"action": "upload", "id": upload_id},
63
- "draft": draft,
64
- }
65
- if current:
66
- body["current_step"] = current
67
- raw = http.post("/api/v3/steps", json_body=body, auth=True)
68
- if not isinstance(raw, dict):
69
- raise WallaUnsupportedError("steps returned non-object")
70
- return raw
71
-
72
-
73
48
  def upload_pictures(upload_id: str, photos: list[Path]) -> int:
74
49
  """POST each photo to /api/v3/upload/{id}/pictures (HTTP 204)."""
75
50
  if not photos:
76
51
  raise ValueError("at least one photo required")
77
- headers = {k: v for k, v in _auth_headers().items() if k.lower() != "content-type"}
52
+ headers = {k: v for k, v in auth_headers().items() if k.lower() != "content-type"}
78
53
  session: Any = requests.Session()
79
54
  for path in photos:
80
55
  data = path.read_bytes()
@@ -101,7 +76,7 @@ def upload_pictures(upload_id: str, photos: list[Path]) -> int:
101
76
 
102
77
  def create_listing(item: dict[str, Any], first_photo: Path) -> dict[str, Any]:
103
78
  """Multipart POST /api/v3/items with Accept upload-v2."""
104
- headers = {k: v for k, v in _auth_headers().items() if k.lower() != "content-type"}
79
+ headers = {k: v for k, v in auth_headers().items() if k.lower() != "content-type"}
105
80
  headers["Accept"] = _UPLOAD_ACCEPT
106
81
  data = first_photo.read_bytes()
107
82
  ctype = _IMAGE_TYPES.get(first_photo.suffix.lower(), "image/jpeg")
@@ -130,7 +105,7 @@ def create_listing(item: dict[str, Any], first_photo: Path) -> dict[str, Any]:
130
105
 
131
106
 
132
107
  def delete_listing(item_id: str, *, client: HttpClient | None = None) -> dict[str, Any]:
133
- http = client or _auth_client()
108
+ http = client or auth_client()
134
109
  http.delete(f"/api/v3/items/{item_id}", auth=True)
135
110
  return {"id": item_id, "deleted": True}
136
111
 
@@ -154,34 +129,26 @@ def publish_listing(
154
129
  for p in paths:
155
130
  if not p.is_file():
156
131
  raise ValueError(f"photo not found: {p}")
157
- http = _auth_client()
132
+ http = auth_client()
158
133
  upload_id = str(uuid.uuid4())
159
134
  draft: dict[str, Any] = {"title": title.strip()[:50]}
160
- _step(http, upload_id, current=None, draft={})
161
- _step(http, upload_id, current="title", draft=draft)
135
+ post_step(http, upload_id, current=None, draft={})
136
+ post_step(http, upload_id, current="title", draft=draft)
162
137
  upload_pictures(upload_id, paths)
163
- _step(http, upload_id, current="photo", draft=draft)
164
- draft = {
165
- **draft,
166
- "category_leaf_id": str(category_leaf_id),
167
- "root_category_id": str(root_category_id),
168
- }
169
- _step(http, upload_id, current="category", draft=draft)
170
- for _ in range(8):
171
- wait_turn()
172
- try:
173
- http.get(f"/api/v3/suggested-item-data/{upload_id}", auth=True)
174
- break
175
- except WallaHTTPError as exc:
176
- if exc.status_code != 404:
177
- raise
178
- _step(http, upload_id, current="loading", draft=draft)
138
+ after_photo = post_step(http, upload_id, current="photo", draft=draft)
139
+ wire = parse_steps_draft(after_photo)
140
+ leaf = str(category_leaf_id)
141
+ root = str(root_category_id)
142
+ draft = {**draft, "category_leaf_id": leaf, "root_category_id": root}
143
+ post_step(http, upload_id, current="category", draft=draft)
144
+ poll_suggested(http, upload_id)
145
+ post_step(http, upload_id, current="loading", draft=draft)
179
146
  item = build_item_body(
180
147
  upload_id=upload_id,
181
148
  title=title,
182
149
  description=description,
183
150
  price_eur=price_eur,
184
- category_leaf_id=category_leaf_id,
151
+ category_leaf_id=leaf,
185
152
  lat=lat,
186
153
  lon=lon,
187
154
  condition=condition,
@@ -197,11 +164,11 @@ def publish_listing(
197
164
  "title": title.strip()[:50],
198
165
  "url": _public_item_url(str(created["id"])),
199
166
  "photos": len(paths),
167
+ "wire_draft": wire,
200
168
  }
201
169
 
202
170
 
203
171
  def _public_item_url(item_id: str) -> str:
204
- """Wallapop web needs the slug path, not the opaque item hash."""
205
172
  try:
206
173
  listing = get_item(item_id)
207
174
  except (
@@ -222,7 +189,7 @@ def _public_item_url(item_id: str) -> str:
222
189
 
223
190
 
224
191
  def _upload_extra_pictures(item_id: str, photos: list[Path]) -> None:
225
- headers = {k: v for k, v in _auth_headers().items() if k.lower() != "content-type"}
192
+ headers = {k: v for k, v in auth_headers().items() if k.lower() != "content-type"}
226
193
  headers["Accept"] = _UPLOAD_ACCEPT
227
194
  session: Any = requests.Session()
228
195
  for idx, path in enumerate(photos, start=1):