walla-cli 0.1.10__tar.gz → 0.1.12__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.10 → walla_cli-0.1.12}/PKG-INFO +1 -1
  2. {walla_cli-0.1.10 → walla_cli-0.1.12}/docs/WIRE.md +6 -0
  3. walla_cli-0.1.12/fixtures/sell_suggest_draft.json +23 -0
  4. {walla_cli-0.1.10 → walla_cli-0.1.12}/pyproject.toml +1 -1
  5. {walla_cli-0.1.10 → walla_cli-0.1.12}/skills/walla-sell/SKILL.md +10 -8
  6. {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_live.py +2 -1
  7. {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_sell.py +71 -13
  8. walla_cli-0.1.12/tests/test_suggest_watch.py +101 -0
  9. {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_wire_contracts.py +3 -2
  10. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/__init__.py +1 -1
  11. walla_cli-0.1.12/walla/account/desk_watch.py +36 -0
  12. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/sell.py +74 -99
  13. walla_cli-0.1.12/walla/account/sell_steps.py +82 -0
  14. walla_cli-0.1.12/walla/account/sell_suggest.py +77 -0
  15. walla_cli-0.1.12/walla/account/sell_upload.py +51 -0
  16. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/cli/cmd_desk.py +12 -2
  17. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/cli/cmd_sell.py +79 -18
  18. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/core/instruct.py +4 -0
  19. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/mcp/server.py +10 -2
  20. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/search/category_find.py +4 -4
  21. {walla_cli-0.1.10 → walla_cli-0.1.12}/.cursor/rules/quality.mdc +0 -0
  22. {walla_cli-0.1.10 → walla_cli-0.1.12}/.github/workflows/ci.yml +0 -0
  23. {walla_cli-0.1.10 → walla_cli-0.1.12}/.gitignore +0 -0
  24. {walla_cli-0.1.10 → walla_cli-0.1.12}/AGENTS.md +0 -0
  25. {walla_cli-0.1.10 → walla_cli-0.1.12}/LICENSE +0 -0
  26. {walla_cli-0.1.10 → walla_cli-0.1.12}/README.md +0 -0
  27. {walla_cli-0.1.10 → walla_cli-0.1.12}/docs/assets/walla-demo-poster.jpg +0 -0
  28. {walla_cli-0.1.10 → walla_cli-0.1.12}/docs/assets/walla-demo-preview.gif +0 -0
  29. {walla_cli-0.1.10 → walla_cli-0.1.12}/docs/assets/walla-demo.mp4 +0 -0
  30. {walla_cli-0.1.10 → walla_cli-0.1.12}/docs/assets/walla-github-banner.jpg +0 -0
  31. {walla_cli-0.1.10 → walla_cli-0.1.12}/docs/code_quality.md +0 -0
  32. {walla_cli-0.1.10 → walla_cli-0.1.12}/docs/data_engineering_standards.md +0 -0
  33. {walla_cli-0.1.10 → walla_cli-0.1.12}/docs/mcp.md +0 -0
  34. {walla_cli-0.1.10 → walla_cli-0.1.12}/docs/objective_function.md +0 -0
  35. {walla_cli-0.1.10 → walla_cli-0.1.12}/docs/rate_limit_analysis.md +0 -0
  36. {walla_cli-0.1.10 → walla_cli-0.1.12}/fixtures/categories.json +0 -0
  37. {walla_cli-0.1.10 → walla_cli-0.1.12}/fixtures/item_detail.json +0 -0
  38. {walla_cli-0.1.10 → walla_cli-0.1.12}/fixtures/offer_buyer_request.json +0 -0
  39. {walla_cli-0.1.10 → walla_cli-0.1.12}/fixtures/search_bicicleta.json +0 -0
  40. {walla_cli-0.1.10 → walla_cli-0.1.12}/fixtures/sell_item_request.json +0 -0
  41. {walla_cli-0.1.10 → walla_cli-0.1.12}/fixtures/wire_contracts.json +0 -0
  42. {walla_cli-0.1.10 → walla_cli-0.1.12}/scripts/check_code_quality.py +0 -0
  43. {walla_cli-0.1.10 → walla_cli-0.1.12}/scripts/dogfood_run.py +0 -0
  44. {walla_cli-0.1.10 → walla_cli-0.1.12}/scripts/probe_rate.py +0 -0
  45. {walla_cli-0.1.10 → walla_cli-0.1.12}/skills/walla/SKILL.md +0 -0
  46. {walla_cli-0.1.10 → walla_cli-0.1.12}/skills/walla-buy/SKILL.md +0 -0
  47. {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_account.py +0 -0
  48. {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_account_more.py +0 -0
  49. {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_catch.py +0 -0
  50. {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_categories_find.py +0 -0
  51. {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_chat_open.py +0 -0
  52. {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_cli_json.py +0 -0
  53. {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_core.py +0 -0
  54. {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_desk.py +0 -0
  55. {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_edges.py +0 -0
  56. {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_export.py +0 -0
  57. {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_http_more.py +0 -0
  58. {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_negotiate.py +0 -0
  59. {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_parsers.py +0 -0
  60. {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_polite.py +0 -0
  61. {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_regressions.py +0 -0
  62. {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_search_api.py +0 -0
  63. {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_session_store.py +0 -0
  64. {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_shortlist.py +0 -0
  65. {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_verdict.py +0 -0
  66. {walla_cli-0.1.10 → walla_cli-0.1.12}/uv.lock +0 -0
  67. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/__init__.py +0 -0
  68. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/actions.py +0 -0
  69. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/chat.py +0 -0
  70. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/cookies.py +0 -0
  71. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/desk.py +0 -0
  72. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/inbox.py +0 -0
  73. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/login.py +0 -0
  74. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/offer_wire.py +0 -0
  75. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/prompt.py +0 -0
  76. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/sell_wire.py +0 -0
  77. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/session_store.py +0 -0
  78. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/cli/__init__.py +0 -0
  79. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/cli/banner.py +0 -0
  80. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/cli/catch.py +0 -0
  81. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/cli/cmd_account.py +0 -0
  82. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/cli/cmd_look.py +0 -0
  83. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/cli/cmd_ops.py +0 -0
  84. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/cli/console.py +0 -0
  85. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/cli/main.py +0 -0
  86. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/core/__init__.py +0 -0
  87. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/core/envelope.py +0 -0
  88. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/core/errors.py +0 -0
  89. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/core/exceptions.py +0 -0
  90. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/core/human_fix.py +0 -0
  91. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/core/path.py +0 -0
  92. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/http/__init__.py +0 -0
  93. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/http/client.py +0 -0
  94. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/http/headers.py +0 -0
  95. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/http/polite.py +0 -0
  96. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/hunter/__init__.py +0 -0
  97. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/hunter/converge.py +0 -0
  98. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/hunter/export.py +0 -0
  99. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/hunter/export_pdf.py +0 -0
  100. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/hunter/negotiate.py +0 -0
  101. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/hunter/profile_store.py +0 -0
  102. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/hunter/pursuits.py +0 -0
  103. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/hunter/shortlist.py +0 -0
  104. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/hunter/stack.py +0 -0
  105. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/hunter/verdict.py +0 -0
  106. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/hunter/watches.py +0 -0
  107. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/mcp/__init__.py +0 -0
  108. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/mcp/_entry.py +0 -0
  109. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/models/__init__.py +0 -0
  110. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/models/account.py +0 -0
  111. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/models/listing.py +0 -0
  112. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/models/profile.py +0 -0
  113. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/search/__init__.py +0 -0
  114. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/search/api.py +0 -0
  115. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/search/parse.py +0 -0
  116. {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/search/pick_category.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: walla-cli
3
- Version: 0.1.10
3
+ Version: 0.1.12
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
@@ -34,6 +34,12 @@ Public URL: `https://es.wallapop.com/item/<web_slug>`.
34
34
  `leaf_id` + `root_id`. Sell accepts `--category <leaf-or-name>`; `--root` is
35
35
  optional.
36
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 …
40
+ --suggest` (explicit only; draft without `--suggest` does not upload).
41
+ `walla desk --watch` polls the inbox until `stack.best` converges.
42
+
37
43
  ## Auth
38
44
 
39
45
  `POST https://api.wallapop.com/api/v3/access/login` with
@@ -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.10"
3
+ version = "0.1.12"
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"
@@ -14,10 +14,12 @@ Do not ask the human to type buyer replies. Desk sends those.
14
14
 
15
15
  ```bash
16
16
  walla categories --find escritorio --json
17
- walla sell ./shot1.jpg ./shot2.jpg --json
17
+ walla sell ./shot1.jpg ./shot2.jpg --title "Escritorio madera" --suggest --json
18
18
  ```
19
19
 
20
- Ask every question in `data.questions`, then publish once:
20
+ `--suggest` uploads photos and asks Wallapop to prefill `category_leaf_id`.
21
+ Draft without `--suggest` stays local (no upload). Ask remaining
22
+ `data.questions`, then:
21
23
 
22
24
  ```bash
23
25
  walla sell ./shot1.jpg ./shot2.jpg \
@@ -26,17 +28,17 @@ walla sell ./shot1.jpg ./shot2.jpg \
26
28
  --floor 35 --yes --json
27
29
  ```
28
30
 
29
- `--category` accepts a leaf id **or** name tokens (`escritorio`, `hogar/muebles`).
30
- `--root` is optional (derived from the leaf). `--floor` is the lowest EUR desk may
31
- accept. Omit it to hold the asking price.
31
+ `--category` accepts a leaf id **or** name tokens. `--root` is optional.
32
+ `--floor` is the lowest EUR desk may accept.
33
+
32
34
  Then:
33
35
 
34
36
  ```bash
35
- walla desk --json
37
+ walla desk --watch --seconds 45 --rounds 12 --json
36
38
  ```
37
39
 
38
- Desk reads the inbox once and answers buyers. Repeat desk until `stack.best`
39
- is a sell row. Show the title, agreed EUR, `stack.why`, and the item link.
40
+ Desk answers buyers until `stack.best` converges or rounds end. Show the title,
41
+ agreed EUR, `stack.why`, and the item link.
40
42
 
41
43
  The human accepts the sale in the Wallapop app. `walla unsell <id> --yes` deletes
42
44
  a listing. Never mark the item sold from walla.
@@ -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
@@ -70,6 +70,63 @@ def test_missing_sell_fields() -> None:
70
70
  assert CONDITIONS[0] == "as_good_as_new"
71
71
 
72
72
 
73
+ def test_sell_draft_with_title_does_not_suggest(tmp_path: Path) -> None:
74
+ from typer.testing import CliRunner
75
+
76
+ from walla.cli.main import app
77
+
78
+ photo = tmp_path / "a.jpg"
79
+ photo.write_bytes(b"\xff\xd8\xff\xd9")
80
+ runner = CliRunner()
81
+ with (
82
+ patch("walla.cli.cmd_sell.load_profile") as lp,
83
+ patch("walla.cli.cmd_sell.suggest_from_photos") as sug,
84
+ ):
85
+ profile = MagicMock()
86
+ profile.lat = 41.39
87
+ profile.lon = 2.17
88
+ profile.label = "BCN"
89
+ lp.return_value = profile
90
+ result = runner.invoke(
91
+ app, ["sell", str(photo), "--title", "Escritorio", "--json"]
92
+ )
93
+ assert result.exit_code == 0, result.output
94
+ sug.assert_not_called()
95
+ payload = json.loads(result.output)
96
+ assert payload["data"]["status"] == "draft"
97
+ assert "suggested" not in payload["data"]
98
+
99
+
100
+ def test_publish_prepared_reuses_upload(tmp_path: Path) -> None:
101
+ from walla.account.sell import publish_prepared
102
+
103
+ photo = tmp_path / "a.jpg"
104
+ photo.write_bytes(b"\xff\xd8\xff\xd9")
105
+ listing = MagicMock()
106
+ listing.url = "https://es.wallapop.com/item/x"
107
+ listing.web_slug = "x"
108
+ with (
109
+ patch(
110
+ "walla.account.sell.create_listing",
111
+ return_value={"id": "abc", "flags": {}},
112
+ ) as create,
113
+ patch("walla.account.sell.get_item", return_value=listing),
114
+ ):
115
+ out = publish_prepared(
116
+ [photo],
117
+ upload_id="upload-1",
118
+ title="T",
119
+ description="D",
120
+ price_eur=10,
121
+ category_leaf_id="24208",
122
+ lat=41.0,
123
+ lon=2.0,
124
+ )
125
+ assert out["reused_suggest"] is True
126
+ assert out["upload_id"] == "upload-1"
127
+ create.assert_called_once()
128
+
129
+
73
130
  def test_sell_cmd_draft_without_yes(tmp_path: Path) -> None:
74
131
  from typer.testing import CliRunner
75
132
 
@@ -119,23 +176,23 @@ def test_publish_listing_mocked(tmp_path: Path) -> None:
119
176
  photo.write_bytes(b"\xff\xd8\xff\xd9")
120
177
  fake_http = MagicMock()
121
178
  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"}},
179
+ {"step": {"id": "title"}, "draft": {}},
180
+ {"step": {"id": "photo"}, "draft": {"title": "Test item"}},
125
181
  {
126
- "step": {
127
- "id": "loading",
128
- "poll_before_next_step": {"path": "/api/v3/suggested-item-data/x"},
129
- }
182
+ "step": {"id": "category"},
183
+ "draft": {"title": "Test item", "category_leaf_id": "10105"},
130
184
  },
131
- {"step": {"id": "listing"}},
185
+ {"step": {"id": "loading"}, "draft": {"title": "Test item"}},
186
+ {"step": {"id": "listing"}, "draft": {"title": "Test item"}},
132
187
  ]
133
188
  fake_http.get.return_value = {}
134
189
  listing = MagicMock()
135
190
  listing.url = "https://es.wallapop.com/item/test-item-99"
136
191
  listing.web_slug = "test-item-99"
137
192
  with (
138
- patch("walla.account.sell._auth_client", return_value=fake_http),
193
+ patch("walla.account.sell.auth_client", return_value=fake_http),
194
+ patch("walla.account.sell.post_step", side_effect=fake_http.post.side_effect),
195
+ patch("walla.account.sell.poll_suggested", return_value=None),
139
196
  patch("walla.account.sell.upload_pictures", return_value=1),
140
197
  patch(
141
198
  "walla.account.sell.create_listing",
@@ -156,7 +213,6 @@ def test_publish_listing_mocked(tmp_path: Path) -> None:
156
213
  assert out["id"] == "abc123"
157
214
  assert out["photos"] == 1
158
215
  assert out["url"] == "https://es.wallapop.com/item/test-item-99"
159
- assert fake_http.post.call_count == 5
160
216
 
161
217
 
162
218
  def test_create_listing_multipart(tmp_path: Path) -> None:
@@ -179,7 +235,7 @@ def test_create_listing_multipart(tmp_path: Path) -> None:
179
235
  fake_session = MagicMock()
180
236
  fake_session.request.return_value = fake_resp
181
237
  with (
182
- patch("walla.account.sell._auth_headers", return_value={"Authorization": "Bearer x"}),
238
+ patch("walla.account.sell.auth_headers", return_value={"Authorization": "Bearer x"}),
183
239
  patch("walla.account.sell.requests.Session", return_value=fake_session),
184
240
  patch("walla.account.sell.CurlMime") as mime_cls,
185
241
  patch("walla.account.sell.wait_turn"),
@@ -227,14 +283,16 @@ def test_publish_listing_two_photos(tmp_path: Path) -> None:
227
283
  listing.url = "https://es.wallapop.com/item/test-item-99"
228
284
  listing.web_slug = "test-item-99"
229
285
  with (
230
- patch("walla.account.sell._auth_client", return_value=fake_http),
286
+ patch("walla.account.sell.auth_client", return_value=fake_http),
287
+ patch("walla.account.sell.post_step", side_effect=fake_http.post.side_effect),
288
+ patch("walla.account.sell.poll_suggested", return_value=None),
231
289
  patch("walla.account.sell.upload_pictures", return_value=2),
232
290
  patch(
233
291
  "walla.account.sell.create_listing",
234
292
  return_value={"id": "abc123", "flags": {}},
235
293
  ),
236
294
  patch("walla.account.sell.get_item", return_value=listing),
237
- patch("walla.account.sell._auth_headers", return_value={}),
295
+ patch("walla.account.sell.auth_headers", return_value={}),
238
296
  patch("walla.account.sell.requests.Session", return_value=fake_session),
239
297
  patch("walla.account.sell.CurlMime") as mime_cls,
240
298
  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.10"
7
+ __version__ = "0.1.12"
@@ -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,14 @@ 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
+ )
19
+ from walla.account.sell_upload import IMAGE_TYPES, upload_pictures
13
20
  from walla.account.sell_wire import build_item_body
14
21
  from walla.core.exceptions import (
15
22
  WallaHTTPError,
@@ -18,7 +25,7 @@ from walla.core.exceptions import (
18
25
  WallaUnsupportedError,
19
26
  )
20
27
  from walla.http.client import HttpClient
21
- from walla.http.headers import API_BASE, WEB_BASE, default_headers
28
+ from walla.http.headers import API_BASE, WEB_BASE
22
29
  from walla.http.polite import wait_turn
23
30
  from walla.search.api import get_item
24
31
  from walla.search.parse import item_url
@@ -27,84 +34,19 @@ __all__ = (
27
34
  "create_listing",
28
35
  "delete_listing",
29
36
  "publish_listing",
37
+ "publish_prepared",
30
38
  "upload_pictures",
31
39
  )
32
40
 
33
41
  _UPLOAD_ACCEPT = "application/vnd.upload-v2+json"
34
- _IMAGE_TYPES = {
35
- ".jpg": "image/jpeg",
36
- ".jpeg": "image/jpeg",
37
- ".png": "image/png",
38
- ".webp": "image/webp",
39
- }
40
-
41
-
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
- def upload_pictures(upload_id: str, photos: list[Path]) -> int:
74
- """POST each photo to /api/v3/upload/{id}/pictures (HTTP 204)."""
75
- if not photos:
76
- raise ValueError("at least one photo required")
77
- headers = {k: v for k, v in _auth_headers().items() if k.lower() != "content-type"}
78
- session: Any = requests.Session()
79
- for path in photos:
80
- data = path.read_bytes()
81
- ctype = _IMAGE_TYPES.get(path.suffix.lower(), "image/jpeg")
82
- wait_turn()
83
- mp = CurlMime()
84
- mp.addpart(name="file", content_type=ctype, filename=path.name, data=data)
85
- resp: Any = session.request(
86
- "POST",
87
- f"{API_BASE}/api/v3/upload/{upload_id}/pictures",
88
- headers=headers,
89
- multipart=mp,
90
- impersonate="chrome",
91
- timeout=60,
92
- )
93
- mp.close()
94
- if resp.status_code not in (200, 201, 204):
95
- raise WallaHTTPError(
96
- f"picture upload HTTP {resp.status_code}: {(resp.text or '')[:200]}",
97
- status_code=resp.status_code,
98
- )
99
- return len(photos)
100
42
 
101
43
 
102
44
  def create_listing(item: dict[str, Any], first_photo: Path) -> dict[str, Any]:
103
45
  """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"}
46
+ headers = {k: v for k, v in auth_headers().items() if k.lower() != "content-type"}
105
47
  headers["Accept"] = _UPLOAD_ACCEPT
106
48
  data = first_photo.read_bytes()
107
- ctype = _IMAGE_TYPES.get(first_photo.suffix.lower(), "image/jpeg")
49
+ ctype = IMAGE_TYPES.get(first_photo.suffix.lower(), "image/jpeg")
108
50
  wait_turn()
109
51
  mp = CurlMime()
110
52
  mp.addpart(name="image", content_type=ctype, filename=first_photo.name, data=data)
@@ -130,52 +72,33 @@ def create_listing(item: dict[str, Any], first_photo: Path) -> dict[str, Any]:
130
72
 
131
73
 
132
74
  def delete_listing(item_id: str, *, client: HttpClient | None = None) -> dict[str, Any]:
133
- http = client or _auth_client()
75
+ http = client or auth_client()
134
76
  http.delete(f"/api/v3/items/{item_id}", auth=True)
135
77
  return {"id": item_id, "deleted": True}
136
78
 
137
79
 
138
- def publish_listing(
80
+ def publish_prepared(
139
81
  photos: list[Path],
140
82
  *,
83
+ upload_id: str,
141
84
  title: str,
142
85
  description: str,
143
86
  price_eur: float,
144
87
  category_leaf_id: str,
145
- root_category_id: str,
146
88
  lat: float,
147
89
  lon: float,
148
90
  condition: str = "good",
149
91
  shipping: bool = False,
150
92
  weight_kg: float | None = None,
93
+ wire_draft: dict[str, str] | None = None,
151
94
  ) -> dict[str, Any]:
152
- """Full Vender path. Caller must already require --yes."""
95
+ """Create item from an upload_id already walked through loading."""
153
96
  paths = [Path(p) for p in photos]
154
97
  for p in paths:
155
98
  if not p.is_file():
156
99
  raise ValueError(f"photo not found: {p}")
157
- http = _auth_client()
158
- upload_id = str(uuid.uuid4())
159
- 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)
162
- 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)
100
+ if not upload_id.strip():
101
+ raise ValueError("upload_id required")
179
102
  item = build_item_body(
180
103
  upload_id=upload_id,
181
104
  title=title,
@@ -197,11 +120,63 @@ def publish_listing(
197
120
  "title": title.strip()[:50],
198
121
  "url": _public_item_url(str(created["id"])),
199
122
  "photos": len(paths),
123
+ "wire_draft": wire_draft or {},
124
+ "reused_suggest": True,
200
125
  }
201
126
 
202
127
 
128
+ def publish_listing(
129
+ photos: list[Path],
130
+ *,
131
+ title: str,
132
+ description: str,
133
+ price_eur: float,
134
+ category_leaf_id: str,
135
+ root_category_id: str,
136
+ lat: float,
137
+ lon: float,
138
+ condition: str = "good",
139
+ shipping: bool = False,
140
+ weight_kg: float | None = None,
141
+ ) -> dict[str, Any]:
142
+ """Full Vender path. Caller must already require --yes."""
143
+ paths = [Path(p) for p in photos]
144
+ for p in paths:
145
+ if not p.is_file():
146
+ raise ValueError(f"photo not found: {p}")
147
+ http = auth_client()
148
+ upload_id = str(uuid.uuid4())
149
+ draft: dict[str, Any] = {"title": title.strip()[:50]}
150
+ post_step(http, upload_id, current=None, draft={})
151
+ post_step(http, upload_id, current="title", draft=draft)
152
+ upload_pictures(upload_id, paths)
153
+ after_photo = post_step(http, upload_id, current="photo", draft=draft)
154
+ wire = parse_steps_draft(after_photo)
155
+ leaf = str(category_leaf_id)
156
+ root = str(root_category_id)
157
+ draft = {**draft, "category_leaf_id": leaf, "root_category_id": root}
158
+ post_step(http, upload_id, current="category", draft=draft)
159
+ poll_suggested(http, upload_id)
160
+ post_step(http, upload_id, current="loading", draft=draft)
161
+ out = publish_prepared(
162
+ paths,
163
+ upload_id=upload_id,
164
+ title=title,
165
+ description=description,
166
+ price_eur=price_eur,
167
+ category_leaf_id=leaf,
168
+ lat=lat,
169
+ lon=lon,
170
+ condition=condition,
171
+ shipping=shipping,
172
+ weight_kg=weight_kg,
173
+ wire_draft=wire,
174
+ )
175
+ out["reused_suggest"] = False
176
+ return out
177
+
178
+
203
179
  def _public_item_url(item_id: str) -> str:
204
- """Wallapop web needs the slug path, not the opaque item hash."""
205
180
  try:
206
181
  listing = get_item(item_id)
207
182
  except (
@@ -222,12 +197,12 @@ def _public_item_url(item_id: str) -> str:
222
197
 
223
198
 
224
199
  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"}
200
+ headers = {k: v for k, v in auth_headers().items() if k.lower() != "content-type"}
226
201
  headers["Accept"] = _UPLOAD_ACCEPT
227
202
  session: Any = requests.Session()
228
203
  for idx, path in enumerate(photos, start=1):
229
204
  data = path.read_bytes()
230
- ctype = _IMAGE_TYPES.get(path.suffix.lower(), "image/jpeg")
205
+ ctype = IMAGE_TYPES.get(path.suffix.lower(), "image/jpeg")
231
206
  wait_turn()
232
207
  mp = CurlMime()
233
208
  mp.addpart(name="image", content_type=ctype, filename=path.name, data=data)