walla-cli 0.1.11__tar.gz → 0.1.13__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 (123) hide show
  1. {walla_cli-0.1.11 → walla_cli-0.1.13}/AGENTS.md +5 -1
  2. {walla_cli-0.1.11 → walla_cli-0.1.13}/PKG-INFO +1 -1
  3. {walla_cli-0.1.11 → walla_cli-0.1.13}/docs/WIRE.md +14 -2
  4. {walla_cli-0.1.11 → walla_cli-0.1.13}/fixtures/categories.json +36 -0
  5. walla_cli-0.1.13/fixtures/edit_item_request.json +49 -0
  6. {walla_cli-0.1.11 → walla_cli-0.1.13}/fixtures/wire_contracts.json +13 -0
  7. {walla_cli-0.1.11 → walla_cli-0.1.13}/pyproject.toml +1 -1
  8. {walla_cli-0.1.11 → walla_cli-0.1.13}/skills/walla-sell/SKILL.md +3 -2
  9. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_categories_find.py +11 -0
  10. walla_cli-0.1.13/tests/test_edit.py +94 -0
  11. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_sell.py +57 -0
  12. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_wire_contracts.py +18 -0
  13. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/__init__.py +1 -1
  14. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/account/sell.py +58 -50
  15. walla_cli-0.1.13/walla/account/sell_edit.py +106 -0
  16. walla_cli-0.1.13/walla/account/sell_edit_wire.py +85 -0
  17. walla_cli-0.1.13/walla/account/sell_photos.py +56 -0
  18. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/account/sell_suggest.py +1 -1
  19. walla_cli-0.1.13/walla/account/sell_upload.py +51 -0
  20. walla_cli-0.1.13/walla/cli/cmd_edit.py +89 -0
  21. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/cli/cmd_look.py +10 -2
  22. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/cli/cmd_sell.py +56 -23
  23. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/cli/main.py +2 -1
  24. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/core/instruct.py +8 -4
  25. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/http/client.py +6 -0
  26. walla_cli-0.1.13/walla/http/sign.py +32 -0
  27. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/hunter/shortlist.py +35 -3
  28. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/search/category_find.py +4 -4
  29. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/search/pick_category.py +22 -0
  30. {walla_cli-0.1.11 → walla_cli-0.1.13}/.cursor/rules/quality.mdc +0 -0
  31. {walla_cli-0.1.11 → walla_cli-0.1.13}/.github/workflows/ci.yml +0 -0
  32. {walla_cli-0.1.11 → walla_cli-0.1.13}/.gitignore +0 -0
  33. {walla_cli-0.1.11 → walla_cli-0.1.13}/LICENSE +0 -0
  34. {walla_cli-0.1.11 → walla_cli-0.1.13}/README.md +0 -0
  35. {walla_cli-0.1.11 → walla_cli-0.1.13}/docs/assets/walla-demo-poster.jpg +0 -0
  36. {walla_cli-0.1.11 → walla_cli-0.1.13}/docs/assets/walla-demo-preview.gif +0 -0
  37. {walla_cli-0.1.11 → walla_cli-0.1.13}/docs/assets/walla-demo.mp4 +0 -0
  38. {walla_cli-0.1.11 → walla_cli-0.1.13}/docs/assets/walla-github-banner.jpg +0 -0
  39. {walla_cli-0.1.11 → walla_cli-0.1.13}/docs/code_quality.md +0 -0
  40. {walla_cli-0.1.11 → walla_cli-0.1.13}/docs/data_engineering_standards.md +0 -0
  41. {walla_cli-0.1.11 → walla_cli-0.1.13}/docs/mcp.md +0 -0
  42. {walla_cli-0.1.11 → walla_cli-0.1.13}/docs/objective_function.md +0 -0
  43. {walla_cli-0.1.11 → walla_cli-0.1.13}/docs/rate_limit_analysis.md +0 -0
  44. {walla_cli-0.1.11 → walla_cli-0.1.13}/fixtures/item_detail.json +0 -0
  45. {walla_cli-0.1.11 → walla_cli-0.1.13}/fixtures/offer_buyer_request.json +0 -0
  46. {walla_cli-0.1.11 → walla_cli-0.1.13}/fixtures/search_bicicleta.json +0 -0
  47. {walla_cli-0.1.11 → walla_cli-0.1.13}/fixtures/sell_item_request.json +0 -0
  48. {walla_cli-0.1.11 → walla_cli-0.1.13}/fixtures/sell_suggest_draft.json +0 -0
  49. {walla_cli-0.1.11 → walla_cli-0.1.13}/scripts/check_code_quality.py +0 -0
  50. {walla_cli-0.1.11 → walla_cli-0.1.13}/scripts/dogfood_run.py +0 -0
  51. {walla_cli-0.1.11 → walla_cli-0.1.13}/scripts/probe_rate.py +0 -0
  52. {walla_cli-0.1.11 → walla_cli-0.1.13}/skills/walla/SKILL.md +0 -0
  53. {walla_cli-0.1.11 → walla_cli-0.1.13}/skills/walla-buy/SKILL.md +0 -0
  54. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_account.py +0 -0
  55. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_account_more.py +0 -0
  56. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_catch.py +0 -0
  57. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_chat_open.py +0 -0
  58. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_cli_json.py +0 -0
  59. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_core.py +0 -0
  60. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_desk.py +0 -0
  61. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_edges.py +0 -0
  62. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_export.py +0 -0
  63. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_http_more.py +0 -0
  64. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_live.py +0 -0
  65. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_negotiate.py +0 -0
  66. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_parsers.py +0 -0
  67. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_polite.py +0 -0
  68. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_regressions.py +0 -0
  69. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_search_api.py +0 -0
  70. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_session_store.py +0 -0
  71. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_shortlist.py +0 -0
  72. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_suggest_watch.py +0 -0
  73. {walla_cli-0.1.11 → walla_cli-0.1.13}/tests/test_verdict.py +0 -0
  74. {walla_cli-0.1.11 → walla_cli-0.1.13}/uv.lock +0 -0
  75. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/account/__init__.py +0 -0
  76. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/account/actions.py +0 -0
  77. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/account/chat.py +0 -0
  78. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/account/cookies.py +0 -0
  79. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/account/desk.py +0 -0
  80. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/account/desk_watch.py +0 -0
  81. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/account/inbox.py +0 -0
  82. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/account/login.py +0 -0
  83. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/account/offer_wire.py +0 -0
  84. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/account/prompt.py +0 -0
  85. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/account/sell_steps.py +0 -0
  86. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/account/sell_wire.py +0 -0
  87. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/account/session_store.py +0 -0
  88. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/cli/__init__.py +0 -0
  89. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/cli/banner.py +0 -0
  90. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/cli/catch.py +0 -0
  91. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/cli/cmd_account.py +0 -0
  92. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/cli/cmd_desk.py +0 -0
  93. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/cli/cmd_ops.py +0 -0
  94. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/cli/console.py +0 -0
  95. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/core/__init__.py +0 -0
  96. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/core/envelope.py +0 -0
  97. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/core/errors.py +0 -0
  98. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/core/exceptions.py +0 -0
  99. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/core/human_fix.py +0 -0
  100. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/core/path.py +0 -0
  101. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/http/__init__.py +0 -0
  102. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/http/headers.py +0 -0
  103. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/http/polite.py +0 -0
  104. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/hunter/__init__.py +0 -0
  105. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/hunter/converge.py +0 -0
  106. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/hunter/export.py +0 -0
  107. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/hunter/export_pdf.py +0 -0
  108. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/hunter/negotiate.py +0 -0
  109. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/hunter/profile_store.py +0 -0
  110. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/hunter/pursuits.py +0 -0
  111. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/hunter/stack.py +0 -0
  112. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/hunter/verdict.py +0 -0
  113. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/hunter/watches.py +0 -0
  114. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/mcp/__init__.py +0 -0
  115. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/mcp/_entry.py +0 -0
  116. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/mcp/server.py +0 -0
  117. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/models/__init__.py +0 -0
  118. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/models/account.py +0 -0
  119. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/models/listing.py +0 -0
  120. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/models/profile.py +0 -0
  121. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/search/__init__.py +0 -0
  122. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/search/api.py +0 -0
  123. {walla_cli-0.1.11 → walla_cli-0.1.13}/walla/search/parse.py +0 -0
@@ -57,7 +57,9 @@ Ranks: models 10 → exceptions 15 → http 20 → core 30 → search/account/hu
57
57
 
58
58
  - **Do** keep files <= 250 LOC.
59
59
  - **Do** fail closed; typed `error_type`.
60
- - **Do** require `--yes` for bare say/offer/sell/unsell. `pursue` and `desk` send negotiation chat without a per-message prompt. Never pay.
60
+ - **Do** require `--yes` for bare say/offer/sell/unsell/edit. `pursue` and `desk` send negotiation chat without a per-message prompt. Never pay.
61
+ - **Do** use `walla edit <id> --eur N --yes` to change price/desc (no unsell+republish).
62
+ - **Do** pass leaf id or `motor accesorios` when `--category Accesorios` is ambiguous.
61
63
  - **Don't** commit session.json, cookies, or tokens.
62
64
  - **Don't** invent wire shapes without a fixture.
63
65
  - **Don't** post buyer offers as `{item_id, amount, currency}` (HTTP 400).
@@ -65,3 +67,5 @@ Ranks: models 10 → exceptions 15 → http 20 → core 30 → search/account/hu
65
67
  - **Don't** create listings without `Accept: application/vnd.upload-v2+json`
66
68
  (HTTP 405). Use `walla sell` / `fixtures/sell_item_request.json`.
67
69
  - **Don't** require `.env` credentials; login is cookie paste.
70
+ - **Don't** leave a sticky `must_match` from a prior hunt; clear with
71
+ `walla setup --must ''` or expect `mandate_note` soft shortlist fallback.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: walla-cli
3
- Version: 0.1.11
3
+ Version: 0.1.13
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
@@ -36,7 +36,8 @@ optional.
36
36
 
37
37
  After `current_step=photo`, the steps `draft` may include `category_leaf_id`
38
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`.
39
+ empty HTTP 200; still poll before `loading`. CLI: `walla sell … --title …
40
+ --suggest` (explicit only; draft without `--suggest` does not upload).
40
41
  `walla desk --watch` polls the inbox until `stack.best` converges.
41
42
 
42
43
  ## Auth
@@ -80,14 +81,25 @@ Browser: `https://es.wallapop.com/app/catalog/upload/consumer-goods`.
80
81
  8. Create: `POST /api/v3/items` multipart `image` + `item` (JSON string).
81
82
  Header **`Accept: application/vnd.upload-v2+json`** (without it → HTTP 405).
82
83
  Extra photos: `POST /api/v3/items/{id}/picture2` multipart `image` + `order`.
83
- 9. Delete: `DELETE /api/v3/items/{id}` → 204.
84
+ 9. Edit owned item: `PUT /api/v3/items/{id}` JSON body (same attribute shape as
85
+ create, plus `pictures: [{id, order}]`). Headers: Bearer,
86
+ **`Accept: application/vnd.upload-v2+json`**, **`X-Signature`** +
87
+ **`Timestamp`** (HMAC of `PUT|{absolute_url}|{ts_ms}|`). HTTP **204**.
88
+ Fixture: `fixtures/edit_item_request.json` / `walla.account.sell_edit_wire`.
89
+ CLI: `walla edit <id> --eur N --desc '…' --yes`.
90
+ 10. Delete: `DELETE /api/v3/items/{id}` → 204.
84
91
 
85
92
  Fixture: `fixtures/sell_item_request.json` / `walla.account.sell_wire.build_item_body`.
93
+ HEIC/HEIF sell photos are converted to JPEG via ImageMagick (`magick`/`convert`)
94
+ in `walla.account.sell_photos` before upload.
86
95
 
87
96
  Every CLI call (method, path, auth, body) is listed in `fixtures/wire_contracts.json`.
88
97
  `scripts/check_code_quality.py` fails if `walla/account`, `walla/search`, or `walla/http`
89
98
  grows a `/api/` or `/bff/` literal that the catalog does not list.
90
99
  CLI: `walla sell photo.jpg … --yes` (draft without `--yes`).
100
+ Bare `Accesorios` is ambiguous across Motor / Cocina — pass leaf id or
101
+ `motor accesorios`. Sticky `must_match` that empties a shortlist triggers a soft
102
+ fallback + `mandate_note` (`walla setup --must ''` to clear).
91
103
 
92
104
  Writes that fail closed as `error_type: unsupported` until fixtures exist.
93
105
 
@@ -35,6 +35,42 @@
35
35
  ]
36
36
  }
37
37
  ]
38
+ },
39
+ {
40
+ "id": 12484,
41
+ "name": "Cocina",
42
+ "parent_id": 12467,
43
+ "subcategories": [
44
+ {
45
+ "id": 24235,
46
+ "name": "Accesorios",
47
+ "parent_id": 12484,
48
+ "subcategories": []
49
+ }
50
+ ]
51
+ }
52
+ ],
53
+ "category_leaf_selection_mandatory": false
54
+ },
55
+ {
56
+ "id": 12800,
57
+ "name": "Motor y accesorios",
58
+ "path": "",
59
+ "vertical_id": "consumer_goods",
60
+ "icon": "engine",
61
+ "subcategories": [
62
+ {
63
+ "id": 10167,
64
+ "name": "Recambios de coches y furgonetas",
65
+ "parent_id": 12800,
66
+ "subcategories": [
67
+ {
68
+ "id": 10328,
69
+ "name": "Accesorios",
70
+ "parent_id": 10167,
71
+ "subcategories": []
72
+ }
73
+ ]
38
74
  }
39
75
  ],
40
76
  "category_leaf_selection_mandatory": false
@@ -0,0 +1,49 @@
1
+ {
2
+ "endpoint": "PUT /api/v3/items/{id}",
3
+ "accept": "application/vnd.upload-v2+json",
4
+ "auth": "Bearer + X-Signature + Timestamp",
5
+ "signature": {
6
+ "message": "PUT|{absolute_url}|{timestamp_ms}|",
7
+ "note": "HMAC-SHA256 with Wallapop consumer key; required for edit"
8
+ },
9
+ "required_keys": [
10
+ "attributes",
11
+ "category_leaf_id",
12
+ "apply_discount",
13
+ "delivery",
14
+ "location",
15
+ "pictures"
16
+ ],
17
+ "attribute_required_keys": [
18
+ "title",
19
+ "description",
20
+ "price_amount",
21
+ "condition"
22
+ ],
23
+ "example": {
24
+ "attributes": {
25
+ "title": "TEST walla CLI edit - borrar",
26
+ "description": "Anuncio de prueba edit. NO COMPRAR.",
27
+ "price_amount": 9,
28
+ "condition": "good"
29
+ },
30
+ "category_leaf_id": "10105",
31
+ "apply_discount": false,
32
+ "delivery": {
33
+ "allowed_by_user": false,
34
+ "max_weight_kg": null,
35
+ "cost_configuration_id": null
36
+ },
37
+ "location": {
38
+ "latitude": 41.3874,
39
+ "longitude": 2.1686,
40
+ "approximated": true
41
+ },
42
+ "pictures": [
43
+ {
44
+ "id": 0,
45
+ "order": 0
46
+ }
47
+ ]
48
+ }
49
+ }
@@ -197,6 +197,19 @@
197
197
  "multipart": ["image", "order"],
198
198
  "ok_status": [200, 201, 204]
199
199
  },
200
+ {
201
+ "id": "sell_edit",
202
+ "cli": "edit",
203
+ "method": "PUT",
204
+ "path": "/api/v3/items/{id}",
205
+ "needle": "/api/v3/items/",
206
+ "auth": true,
207
+ "accept": "application/vnd.upload-v2+json",
208
+ "signed": true,
209
+ "ok_status": [200, 201, 204],
210
+ "fixture": "edit_item_request.json",
211
+ "known_bad": [{"status": 404, "cause": "missing X-Signature / upload-v2 Accept"}]
212
+ },
200
213
  {
201
214
  "id": "unsell",
202
215
  "cli": "unsell",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "walla-cli"
3
- version = "0.1.11"
3
+ version = "0.1.13"
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"
@@ -17,8 +17,9 @@ walla categories --find escritorio --json
17
17
  walla sell ./shot1.jpg ./shot2.jpg --title "Escritorio madera" --suggest --json
18
18
  ```
19
19
 
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
+ `--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:
22
23
 
23
24
  ```bash
24
25
  walla sell ./shot1.jpg ./shot2.jpg \
@@ -48,6 +48,17 @@ def test_pick_by_name_and_id() -> None:
48
48
  assert forced.root_id == "12579"
49
49
 
50
50
 
51
+ def test_accesorios_ambiguous_unless_disambiguated() -> None:
52
+ roots = _roots()
53
+ with pytest.raises(ValueError, match="ambiguous"):
54
+ pick_sell_category(roots, category="Accesorios")
55
+ motor = pick_sell_category(roots, category="motor accesorios")
56
+ assert motor.leaf_id == "10328"
57
+ assert motor.root_id == "12800"
58
+ by_id = pick_sell_category(roots, category="10328")
59
+ assert by_id.leaf_id == "10328"
60
+
61
+
51
62
  def test_find_empty_and_unknown() -> None:
52
63
  with pytest.raises(ValueError, match="empty"):
53
64
  find_categories(_roots(), " ")
@@ -0,0 +1,94 @@
1
+ """Edit-item fixture + soft shortlist / HEIC prep."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from unittest.mock import MagicMock, patch
8
+
9
+ import pytest
10
+
11
+ from walla.account.sell_edit_wire import assert_edit_body, build_edit_body
12
+ from walla.account.sell_photos import prepare_sell_photos
13
+ from walla.http.sign import sign_request
14
+ from walla.hunter.shortlist import soft_shortlist
15
+
16
+ ROOT = Path(__file__).resolve().parents[1]
17
+ FIX = ROOT / "fixtures" / "edit_item_request.json"
18
+
19
+
20
+ def test_edit_fixture_matches_builder() -> None:
21
+ meta = json.loads(FIX.read_text(encoding="utf-8"))
22
+ example = meta["example"]
23
+ assert_edit_body(example)
24
+ body = build_edit_body(
25
+ title=example["attributes"]["title"],
26
+ description=example["attributes"]["description"],
27
+ price_eur=float(example["attributes"]["price_amount"]),
28
+ category_leaf_id=str(example["category_leaf_id"]),
29
+ condition=example["attributes"]["condition"],
30
+ lat=float(example["location"]["latitude"]),
31
+ lon=float(example["location"]["longitude"]),
32
+ pictures=list(example["pictures"]),
33
+ shipping=False,
34
+ )
35
+ for key in meta["required_keys"]:
36
+ assert key in body
37
+ assert body["pictures"][0]["order"] == 0
38
+
39
+
40
+ def test_sign_request_stable() -> None:
41
+ sig = sign_request(
42
+ method="PUT",
43
+ url="https://api.wallapop.com/api/v3/items/abc",
44
+ timestamp_ms="1700000000000",
45
+ )
46
+ assert isinstance(sig, str) and len(sig) > 20
47
+
48
+
49
+ def test_soft_shortlist_mandate_fallback() -> None:
50
+ rows = [
51
+ {
52
+ "id": "a",
53
+ "title": "ski jacket",
54
+ "verdict": "PASS",
55
+ "price": {"amount": 50},
56
+ "url": "https://es.wallapop.com/item/a",
57
+ },
58
+ {
59
+ "id": "b",
60
+ "title": "other",
61
+ "verdict": "PASS",
62
+ "price": {"amount": 40},
63
+ "url": "https://es.wallapop.com/item/b",
64
+ },
65
+ ]
66
+ hard, note = soft_shortlist(rows, must_match=["north", "reach"])
67
+ assert hard
68
+ assert note is not None
69
+ assert note["mandate_emptied"] is True
70
+ assert [r["id"] for r in hard] == ["b", "a"]
71
+
72
+
73
+ def test_prepare_rejects_unknown(tmp_path: Path) -> None:
74
+ bad = tmp_path / "x.gif"
75
+ bad.write_bytes(b"GIF")
76
+ with pytest.raises(ValueError, match="unsupported"):
77
+ prepare_sell_photos([bad])
78
+
79
+
80
+ def test_prepare_heic_uses_convert(tmp_path: Path) -> None:
81
+ src = tmp_path / "shot.HEIC"
82
+ src.write_bytes(b"heic")
83
+ dest = tmp_path / "shot.jpg"
84
+
85
+ def _fake_run(cmd: list[str], **_: object) -> MagicMock:
86
+ dest.write_bytes(b"\xff\xd8\xff\xd9")
87
+ return MagicMock(returncode=0, stderr="", stdout="")
88
+
89
+ with (
90
+ patch("walla.account.sell_photos.shutil.which", return_value="convert"),
91
+ patch("walla.account.sell_photos.subprocess.run", side_effect=_fake_run),
92
+ ):
93
+ out = prepare_sell_photos([src])
94
+ assert out == [dest]
@@ -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
 
@@ -17,6 +17,7 @@ from walla.account.inbox import list_conversations_raw
17
17
  from walla.account.login import login_password, mint_access_token, refresh_session, whoami
18
18
  from walla.account.offer_wire import assert_offer_body
19
19
  from walla.account.sell import delete_listing, publish_listing
20
+ from walla.account.sell_edit import edit_listing
20
21
  from walla.account.sell_wire import assert_item_body, build_item_body
21
22
  from walla.core.exceptions import WallaAuthError, WallaHTTPError, WallaUnsupportedError
22
23
  from walla.http.client import HttpClient
@@ -244,6 +245,20 @@ def test_every_contract_is_sent(
244
245
  monkeypatch.setattr("walla.account.sell.requests.Session", lambda: _Sess())
245
246
  monkeypatch.setattr("walla.account.sell.CurlMime.addpart", _addpart)
246
247
  monkeypatch.setattr("walla.account.sell.CurlMime.close", lambda _s: None)
248
+ monkeypatch.setattr("walla.account.sell_edit.requests.request", _Sess().request)
249
+ monkeypatch.setattr(
250
+ "walla.account.sell_edit.fetch_owned_item",
251
+ lambda _id: {
252
+ "title": {"original": "Cofre test"},
253
+ "description": {"original": "desc"},
254
+ "price": {"cash": {"amount": 79.0}},
255
+ "type_attributes": {"condition": {"value": "good"}},
256
+ "taxonomy": [{"id": 10328}],
257
+ "location": {"latitude": 41.39, "longitude": 2.17},
258
+ "shipping": {"user_allows_shipping": False},
259
+ "images": [{"id": 1}],
260
+ },
261
+ )
247
262
  sess = SessionData(
248
263
  access_token="tok",
249
264
  device_id="dev-contract",
@@ -302,6 +317,9 @@ def test_every_contract_is_sent(
302
317
  lon=2.17,
303
318
  )
304
319
  assert created["id"] == "item123"
320
+ edited = edit_listing("item123", price_eur=49.0)
321
+ assert edited["edited"] is True
322
+ assert edited["price_eur"] == 49.0
305
323
  delete_listing("item123", client=rec)
306
324
 
307
325
  item_parts = [
@@ -4,4 +4,4 @@ from __future__ import annotations
4
4
 
5
5
  __all__ = ("__version__",)
6
6
 
7
- __version__ = "0.1.11"
7
+ __version__ = "0.1.13"
@@ -16,6 +16,7 @@ from walla.account.sell_steps import (
16
16
  poll_suggested,
17
17
  post_step,
18
18
  )
19
+ from walla.account.sell_upload import IMAGE_TYPES, upload_pictures
19
20
  from walla.account.sell_wire import build_item_body
20
21
  from walla.core.exceptions import (
21
22
  WallaHTTPError,
@@ -33,45 +34,11 @@ __all__ = (
33
34
  "create_listing",
34
35
  "delete_listing",
35
36
  "publish_listing",
37
+ "publish_prepared",
36
38
  "upload_pictures",
37
39
  )
38
40
 
39
41
  _UPLOAD_ACCEPT = "application/vnd.upload-v2+json"
40
- _IMAGE_TYPES = {
41
- ".jpg": "image/jpeg",
42
- ".jpeg": "image/jpeg",
43
- ".png": "image/png",
44
- ".webp": "image/webp",
45
- }
46
-
47
-
48
- def upload_pictures(upload_id: str, photos: list[Path]) -> int:
49
- """POST each photo to /api/v3/upload/{id}/pictures (HTTP 204)."""
50
- if not photos:
51
- raise ValueError("at least one photo required")
52
- headers = {k: v for k, v in auth_headers().items() if k.lower() != "content-type"}
53
- session: Any = requests.Session()
54
- for path in photos:
55
- data = path.read_bytes()
56
- ctype = _IMAGE_TYPES.get(path.suffix.lower(), "image/jpeg")
57
- wait_turn()
58
- mp = CurlMime()
59
- mp.addpart(name="file", content_type=ctype, filename=path.name, data=data)
60
- resp: Any = session.request(
61
- "POST",
62
- f"{API_BASE}/api/v3/upload/{upload_id}/pictures",
63
- headers=headers,
64
- multipart=mp,
65
- impersonate="chrome",
66
- timeout=60,
67
- )
68
- mp.close()
69
- if resp.status_code not in (200, 201, 204):
70
- raise WallaHTTPError(
71
- f"picture upload HTTP {resp.status_code}: {(resp.text or '')[:200]}",
72
- status_code=resp.status_code,
73
- )
74
- return len(photos)
75
42
 
76
43
 
77
44
  def create_listing(item: dict[str, Any], first_photo: Path) -> dict[str, Any]:
@@ -79,7 +46,7 @@ def create_listing(item: dict[str, Any], first_photo: Path) -> dict[str, Any]:
79
46
  headers = {k: v for k, v in auth_headers().items() if k.lower() != "content-type"}
80
47
  headers["Accept"] = _UPLOAD_ACCEPT
81
48
  data = first_photo.read_bytes()
82
- ctype = _IMAGE_TYPES.get(first_photo.suffix.lower(), "image/jpeg")
49
+ ctype = IMAGE_TYPES.get(first_photo.suffix.lower(), "image/jpeg")
83
50
  wait_turn()
84
51
  mp = CurlMime()
85
52
  mp.addpart(name="image", content_type=ctype, filename=first_photo.name, data=data)
@@ -110,6 +77,54 @@ def delete_listing(item_id: str, *, client: HttpClient | None = None) -> dict[st
110
77
  return {"id": item_id, "deleted": True}
111
78
 
112
79
 
80
+ def publish_prepared(
81
+ photos: list[Path],
82
+ *,
83
+ upload_id: str,
84
+ title: str,
85
+ description: str,
86
+ price_eur: float,
87
+ category_leaf_id: str,
88
+ lat: float,
89
+ lon: float,
90
+ condition: str = "good",
91
+ shipping: bool = False,
92
+ weight_kg: float | None = None,
93
+ wire_draft: dict[str, str] | None = None,
94
+ ) -> dict[str, Any]:
95
+ """Create item from an upload_id already walked through loading."""
96
+ paths = [Path(p) for p in photos]
97
+ for p in paths:
98
+ if not p.is_file():
99
+ raise ValueError(f"photo not found: {p}")
100
+ if not upload_id.strip():
101
+ raise ValueError("upload_id required")
102
+ item = build_item_body(
103
+ upload_id=upload_id,
104
+ title=title,
105
+ description=description,
106
+ price_eur=price_eur,
107
+ category_leaf_id=category_leaf_id,
108
+ lat=lat,
109
+ lon=lon,
110
+ condition=condition,
111
+ shipping=shipping,
112
+ max_weight_kg=weight_kg,
113
+ )
114
+ created = create_listing(item, paths[0])
115
+ if len(paths) > 1:
116
+ _upload_extra_pictures(created["id"], paths[1:])
117
+ return {
118
+ **created,
119
+ "upload_id": upload_id,
120
+ "title": title.strip()[:50],
121
+ "url": _public_item_url(str(created["id"])),
122
+ "photos": len(paths),
123
+ "wire_draft": wire_draft or {},
124
+ "reused_suggest": True,
125
+ }
126
+
127
+
113
128
  def publish_listing(
114
129
  photos: list[Path],
115
130
  *,
@@ -143,7 +158,8 @@ def publish_listing(
143
158
  post_step(http, upload_id, current="category", draft=draft)
144
159
  poll_suggested(http, upload_id)
145
160
  post_step(http, upload_id, current="loading", draft=draft)
146
- item = build_item_body(
161
+ out = publish_prepared(
162
+ paths,
147
163
  upload_id=upload_id,
148
164
  title=title,
149
165
  description=description,
@@ -153,19 +169,11 @@ def publish_listing(
153
169
  lon=lon,
154
170
  condition=condition,
155
171
  shipping=shipping,
156
- max_weight_kg=weight_kg,
172
+ weight_kg=weight_kg,
173
+ wire_draft=wire,
157
174
  )
158
- created = create_listing(item, paths[0])
159
- if len(paths) > 1:
160
- _upload_extra_pictures(created["id"], paths[1:])
161
- return {
162
- **created,
163
- "upload_id": upload_id,
164
- "title": title.strip()[:50],
165
- "url": _public_item_url(str(created["id"])),
166
- "photos": len(paths),
167
- "wire_draft": wire,
168
- }
175
+ out["reused_suggest"] = False
176
+ return out
169
177
 
170
178
 
171
179
  def _public_item_url(item_id: str) -> str:
@@ -194,7 +202,7 @@ def _upload_extra_pictures(item_id: str, photos: list[Path]) -> None:
194
202
  session: Any = requests.Session()
195
203
  for idx, path in enumerate(photos, start=1):
196
204
  data = path.read_bytes()
197
- ctype = _IMAGE_TYPES.get(path.suffix.lower(), "image/jpeg")
205
+ ctype = IMAGE_TYPES.get(path.suffix.lower(), "image/jpeg")
198
206
  wait_turn()
199
207
  mp = CurlMime()
200
208
  mp.addpart(name="image", content_type=ctype, filename=path.name, data=data)