walla-cli 0.1.12__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.12 → walla_cli-0.1.13}/AGENTS.md +5 -1
  2. {walla_cli-0.1.12 → walla_cli-0.1.13}/PKG-INFO +1 -1
  3. {walla_cli-0.1.12 → walla_cli-0.1.13}/docs/WIRE.md +12 -1
  4. {walla_cli-0.1.12 → 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.12 → walla_cli-0.1.13}/fixtures/wire_contracts.json +13 -0
  7. {walla_cli-0.1.12 → walla_cli-0.1.13}/pyproject.toml +1 -1
  8. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_categories_find.py +11 -0
  9. walla_cli-0.1.13/tests/test_edit.py +94 -0
  10. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_wire_contracts.py +18 -0
  11. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/__init__.py +1 -1
  12. walla_cli-0.1.13/walla/account/sell_edit.py +106 -0
  13. walla_cli-0.1.13/walla/account/sell_edit_wire.py +85 -0
  14. walla_cli-0.1.13/walla/account/sell_photos.py +56 -0
  15. walla_cli-0.1.13/walla/cli/cmd_edit.py +89 -0
  16. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/cli/cmd_look.py +10 -2
  17. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/cli/cmd_sell.py +3 -5
  18. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/cli/main.py +2 -1
  19. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/core/instruct.py +7 -3
  20. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/http/client.py +6 -0
  21. walla_cli-0.1.13/walla/http/sign.py +32 -0
  22. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/hunter/shortlist.py +35 -3
  23. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/search/pick_category.py +22 -0
  24. {walla_cli-0.1.12 → walla_cli-0.1.13}/.cursor/rules/quality.mdc +0 -0
  25. {walla_cli-0.1.12 → walla_cli-0.1.13}/.github/workflows/ci.yml +0 -0
  26. {walla_cli-0.1.12 → walla_cli-0.1.13}/.gitignore +0 -0
  27. {walla_cli-0.1.12 → walla_cli-0.1.13}/LICENSE +0 -0
  28. {walla_cli-0.1.12 → walla_cli-0.1.13}/README.md +0 -0
  29. {walla_cli-0.1.12 → walla_cli-0.1.13}/docs/assets/walla-demo-poster.jpg +0 -0
  30. {walla_cli-0.1.12 → walla_cli-0.1.13}/docs/assets/walla-demo-preview.gif +0 -0
  31. {walla_cli-0.1.12 → walla_cli-0.1.13}/docs/assets/walla-demo.mp4 +0 -0
  32. {walla_cli-0.1.12 → walla_cli-0.1.13}/docs/assets/walla-github-banner.jpg +0 -0
  33. {walla_cli-0.1.12 → walla_cli-0.1.13}/docs/code_quality.md +0 -0
  34. {walla_cli-0.1.12 → walla_cli-0.1.13}/docs/data_engineering_standards.md +0 -0
  35. {walla_cli-0.1.12 → walla_cli-0.1.13}/docs/mcp.md +0 -0
  36. {walla_cli-0.1.12 → walla_cli-0.1.13}/docs/objective_function.md +0 -0
  37. {walla_cli-0.1.12 → walla_cli-0.1.13}/docs/rate_limit_analysis.md +0 -0
  38. {walla_cli-0.1.12 → walla_cli-0.1.13}/fixtures/item_detail.json +0 -0
  39. {walla_cli-0.1.12 → walla_cli-0.1.13}/fixtures/offer_buyer_request.json +0 -0
  40. {walla_cli-0.1.12 → walla_cli-0.1.13}/fixtures/search_bicicleta.json +0 -0
  41. {walla_cli-0.1.12 → walla_cli-0.1.13}/fixtures/sell_item_request.json +0 -0
  42. {walla_cli-0.1.12 → walla_cli-0.1.13}/fixtures/sell_suggest_draft.json +0 -0
  43. {walla_cli-0.1.12 → walla_cli-0.1.13}/scripts/check_code_quality.py +0 -0
  44. {walla_cli-0.1.12 → walla_cli-0.1.13}/scripts/dogfood_run.py +0 -0
  45. {walla_cli-0.1.12 → walla_cli-0.1.13}/scripts/probe_rate.py +0 -0
  46. {walla_cli-0.1.12 → walla_cli-0.1.13}/skills/walla/SKILL.md +0 -0
  47. {walla_cli-0.1.12 → walla_cli-0.1.13}/skills/walla-buy/SKILL.md +0 -0
  48. {walla_cli-0.1.12 → walla_cli-0.1.13}/skills/walla-sell/SKILL.md +0 -0
  49. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_account.py +0 -0
  50. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_account_more.py +0 -0
  51. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_catch.py +0 -0
  52. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_chat_open.py +0 -0
  53. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_cli_json.py +0 -0
  54. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_core.py +0 -0
  55. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_desk.py +0 -0
  56. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_edges.py +0 -0
  57. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_export.py +0 -0
  58. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_http_more.py +0 -0
  59. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_live.py +0 -0
  60. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_negotiate.py +0 -0
  61. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_parsers.py +0 -0
  62. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_polite.py +0 -0
  63. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_regressions.py +0 -0
  64. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_search_api.py +0 -0
  65. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_sell.py +0 -0
  66. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_session_store.py +0 -0
  67. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_shortlist.py +0 -0
  68. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_suggest_watch.py +0 -0
  69. {walla_cli-0.1.12 → walla_cli-0.1.13}/tests/test_verdict.py +0 -0
  70. {walla_cli-0.1.12 → walla_cli-0.1.13}/uv.lock +0 -0
  71. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/account/__init__.py +0 -0
  72. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/account/actions.py +0 -0
  73. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/account/chat.py +0 -0
  74. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/account/cookies.py +0 -0
  75. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/account/desk.py +0 -0
  76. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/account/desk_watch.py +0 -0
  77. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/account/inbox.py +0 -0
  78. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/account/login.py +0 -0
  79. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/account/offer_wire.py +0 -0
  80. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/account/prompt.py +0 -0
  81. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/account/sell.py +0 -0
  82. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/account/sell_steps.py +0 -0
  83. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/account/sell_suggest.py +0 -0
  84. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/account/sell_upload.py +0 -0
  85. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/account/sell_wire.py +0 -0
  86. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/account/session_store.py +0 -0
  87. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/cli/__init__.py +0 -0
  88. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/cli/banner.py +0 -0
  89. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/cli/catch.py +0 -0
  90. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/cli/cmd_account.py +0 -0
  91. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/cli/cmd_desk.py +0 -0
  92. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/cli/cmd_ops.py +0 -0
  93. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/cli/console.py +0 -0
  94. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/core/__init__.py +0 -0
  95. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/core/envelope.py +0 -0
  96. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/core/errors.py +0 -0
  97. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/core/exceptions.py +0 -0
  98. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/core/human_fix.py +0 -0
  99. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/core/path.py +0 -0
  100. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/http/__init__.py +0 -0
  101. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/http/headers.py +0 -0
  102. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/http/polite.py +0 -0
  103. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/hunter/__init__.py +0 -0
  104. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/hunter/converge.py +0 -0
  105. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/hunter/export.py +0 -0
  106. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/hunter/export_pdf.py +0 -0
  107. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/hunter/negotiate.py +0 -0
  108. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/hunter/profile_store.py +0 -0
  109. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/hunter/pursuits.py +0 -0
  110. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/hunter/stack.py +0 -0
  111. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/hunter/verdict.py +0 -0
  112. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/hunter/watches.py +0 -0
  113. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/mcp/__init__.py +0 -0
  114. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/mcp/_entry.py +0 -0
  115. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/mcp/server.py +0 -0
  116. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/models/__init__.py +0 -0
  117. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/models/account.py +0 -0
  118. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/models/listing.py +0 -0
  119. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/models/profile.py +0 -0
  120. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/search/__init__.py +0 -0
  121. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/search/api.py +0 -0
  122. {walla_cli-0.1.12 → walla_cli-0.1.13}/walla/search/category_find.py +0 -0
  123. {walla_cli-0.1.12 → 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.12
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
@@ -81,14 +81,25 @@ Browser: `https://es.wallapop.com/app/catalog/upload/consumer-goods`.
81
81
  8. Create: `POST /api/v3/items` multipart `image` + `item` (JSON string).
82
82
  Header **`Accept: application/vnd.upload-v2+json`** (without it → HTTP 405).
83
83
  Extra photos: `POST /api/v3/items/{id}/picture2` multipart `image` + `order`.
84
- 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.
85
91
 
86
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.
87
95
 
88
96
  Every CLI call (method, path, auth, body) is listed in `fixtures/wire_contracts.json`.
89
97
  `scripts/check_code_quality.py` fails if `walla/account`, `walla/search`, or `walla/http`
90
98
  grows a `/api/` or `/bff/` literal that the catalog does not list.
91
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).
92
103
 
93
104
  Writes that fail closed as `error_type: unsupported` until fixtures exist.
94
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.12"
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"
@@ -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]
@@ -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.12"
7
+ __version__ = "0.1.13"
@@ -0,0 +1,106 @@
1
+ """Edit an owned listing (PUT /api/v3/items/{id} + X-Signature)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from curl_cffi import requests
9
+
10
+ from walla.account.sell_edit_wire import build_edit_body
11
+ from walla.account.sell_steps import auth_client, auth_headers
12
+ from walla.core.exceptions import WallaHTTPError, WallaUnsupportedError
13
+ from walla.http.headers import API_BASE
14
+ from walla.http.polite import wait_turn
15
+ from walla.http.sign import signed_headers
16
+
17
+ __all__ = ("edit_listing", "fetch_owned_item")
18
+
19
+
20
+ def fetch_owned_item(item_id: str) -> dict[str, Any]:
21
+ """GET /api/v3/items/{id} with auth (seller detail shape)."""
22
+ raw = auth_client().get(f"/api/v3/items/{item_id}", auth=True)
23
+ if not isinstance(raw, dict):
24
+ raise WallaUnsupportedError("item detail returned non-object")
25
+ return raw
26
+
27
+
28
+ def edit_listing(
29
+ item_id: str,
30
+ *,
31
+ title: str | None = None,
32
+ description: str | None = None,
33
+ price_eur: float | None = None,
34
+ shipping: bool | None = None,
35
+ weight_kg: float | None = None,
36
+ ) -> dict[str, Any]:
37
+ """Patch title/description/price/shipping on an owned item. Needs --yes."""
38
+ item = fetch_owned_item(item_id)
39
+ cur_title = _orig(item.get("title"))
40
+ cur_desc = _orig(item.get("description"))
41
+ price_block = item.get("price") or {}
42
+ cash = price_block.get("cash") if isinstance(price_block, dict) else None
43
+ cur_price = float((cash or {}).get("amount") or 0)
44
+ cond = ((item.get("type_attributes") or {}).get("condition") or {}).get("value")
45
+ if not cond:
46
+ raise WallaUnsupportedError("item missing type_attributes.condition")
47
+ tax = item.get("taxonomy") or []
48
+ if not tax:
49
+ raise WallaUnsupportedError("item missing taxonomy leaf")
50
+ leaf = str(tax[-1]["id"])
51
+ loc = item.get("location") or {}
52
+ ship = item.get("shipping") or {}
53
+ allowed = bool(ship.get("user_allows_shipping"))
54
+ pics = [
55
+ {"id": img["id"], "order": i}
56
+ for i, img in enumerate(item.get("images") or [])
57
+ if isinstance(img, dict) and "id" in img
58
+ ]
59
+ body = build_edit_body(
60
+ title=title if title is not None else cur_title,
61
+ description=description if description is not None else cur_desc,
62
+ price_eur=float(price_eur) if price_eur is not None else cur_price,
63
+ category_leaf_id=leaf,
64
+ condition=str(cond),
65
+ lat=float(loc["latitude"]),
66
+ lon=float(loc["longitude"]),
67
+ pictures=pics,
68
+ shipping=allowed if shipping is None else bool(shipping),
69
+ max_weight_kg=weight_kg,
70
+ )
71
+ _put_edit(item_id, body)
72
+ return {
73
+ "id": item_id,
74
+ "edited": True,
75
+ "title": body["attributes"]["title"],
76
+ "price_eur": body["attributes"]["price_amount"],
77
+ "shipping": body["delivery"]["allowed_by_user"],
78
+ }
79
+
80
+
81
+ def _orig(block: Any) -> str:
82
+ if isinstance(block, dict):
83
+ return str(block.get("original") or "")
84
+ return str(block or "")
85
+
86
+
87
+ def _put_edit(item_id: str, body: dict[str, Any]) -> None:
88
+ url = f"{API_BASE}/api/v3/items/{item_id}"
89
+ headers = {k: v for k, v in auth_headers().items() if k.lower() != "content-type"}
90
+ headers["Accept"] = "application/vnd.upload-v2+json"
91
+ headers["Content-Type"] = "application/json"
92
+ headers = signed_headers(method="PUT", url=url, base=headers)
93
+ wait_turn()
94
+ resp: Any = requests.request(
95
+ "PUT",
96
+ url,
97
+ headers=headers,
98
+ data=json.dumps(body).encode(),
99
+ impersonate="chrome",
100
+ timeout=30,
101
+ )
102
+ if resp.status_code not in (200, 201, 204):
103
+ raise WallaHTTPError(
104
+ f"edit item HTTP {resp.status_code}: {(resp.text or '')[:200]}",
105
+ status_code=resp.status_code,
106
+ )
@@ -0,0 +1,85 @@
1
+ """Edit-item body (fixture-backed PUT /api/v3/items/{id})."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from walla.account.sell_wire import CONDITIONS
8
+
9
+ __all__ = (
10
+ "EDIT_ATTR_KEYS",
11
+ "EDIT_ITEM_KEYS",
12
+ "assert_edit_body",
13
+ "build_edit_body",
14
+ )
15
+
16
+ EDIT_ITEM_KEYS = (
17
+ "attributes",
18
+ "category_leaf_id",
19
+ "apply_discount",
20
+ "delivery",
21
+ "location",
22
+ "pictures",
23
+ )
24
+
25
+ EDIT_ATTR_KEYS = ("title", "description", "price_amount", "condition")
26
+
27
+
28
+ def build_edit_body(
29
+ *,
30
+ title: str,
31
+ description: str,
32
+ price_eur: float,
33
+ category_leaf_id: str,
34
+ condition: str,
35
+ lat: float,
36
+ lon: float,
37
+ pictures: list[dict[str, Any]],
38
+ shipping: bool = False,
39
+ max_weight_kg: float | None = None,
40
+ approximated: bool = False,
41
+ ) -> dict[str, Any]:
42
+ if condition not in CONDITIONS:
43
+ raise ValueError(f"condition must be one of {CONDITIONS}")
44
+ if not pictures:
45
+ raise ValueError("edit body needs at least one picture id")
46
+ body: dict[str, Any] = {
47
+ "attributes": {
48
+ "title": title.strip()[:50],
49
+ "description": description.strip(),
50
+ "price_amount": float(price_eur),
51
+ "condition": condition,
52
+ },
53
+ "category_leaf_id": str(category_leaf_id),
54
+ "apply_discount": False,
55
+ "delivery": {
56
+ "allowed_by_user": bool(shipping),
57
+ "max_weight_kg": (
58
+ float(max_weight_kg) if shipping and max_weight_kg else None
59
+ ),
60
+ "cost_configuration_id": None,
61
+ },
62
+ "location": {
63
+ "latitude": float(lat),
64
+ "longitude": float(lon),
65
+ "approximated": bool(approximated),
66
+ },
67
+ "pictures": pictures,
68
+ }
69
+ assert_edit_body(body)
70
+ return body
71
+
72
+
73
+ def assert_edit_body(body: dict[str, Any]) -> None:
74
+ missing = [k for k in EDIT_ITEM_KEYS if k not in body]
75
+ if missing:
76
+ raise ValueError(f"edit item body missing keys: {missing}")
77
+ attrs = body["attributes"]
78
+ if not isinstance(attrs, dict):
79
+ raise ValueError("edit item attributes must be an object")
80
+ for k in EDIT_ATTR_KEYS:
81
+ if k not in attrs:
82
+ raise ValueError(f"edit item attributes missing {k}")
83
+ pics = body["pictures"]
84
+ if not isinstance(pics, list) or not pics:
85
+ raise ValueError("edit pictures must be a non-empty list")
@@ -0,0 +1,56 @@
1
+ """Normalize sell photo paths (HEIC -> JPEG via ImageMagick when needed)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ from walla.account.sell_upload import IMAGE_TYPES
10
+
11
+ __all__ = ("prepare_sell_photos",)
12
+
13
+ _HEIC = {".heic", ".heif"}
14
+
15
+
16
+ def prepare_sell_photos(photos: list[Path]) -> list[Path]:
17
+ """Return paths Wallapop accepts; convert HEIC/HEIF when convert/magick exists."""
18
+ if not photos:
19
+ raise ValueError("at least one photo required")
20
+ out: list[Path] = []
21
+ for path in photos:
22
+ p = path.expanduser()
23
+ if not p.is_file():
24
+ raise ValueError(f"photo not found: {p}")
25
+ suf = p.suffix.lower()
26
+ if suf in IMAGE_TYPES:
27
+ out.append(p)
28
+ continue
29
+ if suf in _HEIC:
30
+ out.append(_heic_to_jpeg(p))
31
+ continue
32
+ raise ValueError(
33
+ f"unsupported photo type {suf} ({p.name}); use JPEG/PNG/WebP/HEIC"
34
+ )
35
+ return out
36
+
37
+
38
+ def _heic_to_jpeg(src: Path) -> Path:
39
+ dest = src.with_suffix(".jpg")
40
+ if dest.is_file() and dest.stat().st_mtime >= src.stat().st_mtime:
41
+ return dest
42
+ tool = shutil.which("magick") or shutil.which("convert")
43
+ if not tool:
44
+ raise ValueError(
45
+ f"HEIC needs ImageMagick (magick/convert) to encode JPEG: {src.name}"
46
+ )
47
+ cmd = (
48
+ [tool, str(src), str(dest)]
49
+ if Path(tool).name == "convert"
50
+ else [tool, str(src), str(dest)]
51
+ )
52
+ proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
53
+ if proc.returncode != 0 or not dest.is_file():
54
+ err = (proc.stderr or proc.stdout or "").strip()[:200]
55
+ raise ValueError(f"HEIC convert failed for {src.name}: {err or 'unknown'}")
56
+ return dest
@@ -0,0 +1,89 @@
1
+ """Edit owned listings (price / description / shipping)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import typer
8
+
9
+ from walla.account.sell_edit import edit_listing
10
+ from walla.cli.catch import run_cmd
11
+ from walla.hunter.pursuits import Pursuit, load_pursuits, remember
12
+
13
+ __all__ = ("register",)
14
+
15
+
16
+ def register(app: typer.Typer) -> None:
17
+ @app.command("edit")
18
+ def edit_cmd(
19
+ item_id: str = typer.Argument(..., help="Owned listing id"),
20
+ title: str | None = typer.Option(None, "--title"),
21
+ description: str | None = typer.Option(None, "--description", "--desc"),
22
+ eur: float | None = typer.Option(None, "--eur", help="New asking price"),
23
+ ship: bool | None = typer.Option(
24
+ None, "--ship/--no-ship", help="Toggle Wallapop shipping"
25
+ ),
26
+ weight_kg: float | None = typer.Option(None, "--weight-kg"),
27
+ floor: float | None = typer.Option(
28
+ None, "--floor", help="Update desk walk-away EUR for this listing"
29
+ ),
30
+ yes: bool = typer.Option(False, "--yes", help="Apply edit for real"),
31
+ json: bool = typer.Option(False, "--json"),
32
+ ) -> None:
33
+ """Update title, description, price, or shipping on an owned listing."""
34
+
35
+ def _run() -> dict[str, Any]:
36
+ if title is None and description is None and eur is None and ship is None:
37
+ raise ValueError("Pass --title, --desc, --eur, and/or --ship")
38
+ if not yes:
39
+ return {
40
+ "status": "draft",
41
+ "id": item_id,
42
+ "fields": {
43
+ "title": title,
44
+ "description": description,
45
+ "eur": eur,
46
+ "shipping": ship,
47
+ "weight_kg": weight_kg,
48
+ "floor": floor,
49
+ },
50
+ "hint": "Review, then walla edit <id> … --yes",
51
+ }
52
+ edited = edit_listing(
53
+ item_id,
54
+ title=title,
55
+ description=description,
56
+ price_eur=eur,
57
+ shipping=ship,
58
+ weight_kg=weight_kg,
59
+ )
60
+ if floor is not None or eur is not None:
61
+ _touch_floor(item_id, ask=eur, floor=floor)
62
+ return {"status": "edited", **edited}
63
+
64
+ run_cmd(_run, as_json=json)
65
+
66
+
67
+ def _touch_floor(item_id: str, *, ask: float | None, floor: float | None) -> None:
68
+ for row in load_pursuits().pursuits:
69
+ if row.item_id != item_id or row.side != "sell":
70
+ continue
71
+ ask_eur = float(ask) if ask is not None else row.ask_eur
72
+ walk = float(floor) if floor is not None else row.walk_away_eur
73
+ if ask is not None and floor is None:
74
+ walk = min(walk, ask_eur)
75
+ remember(
76
+ Pursuit(
77
+ item_id=row.item_id,
78
+ title=row.title,
79
+ url=row.url,
80
+ side="sell",
81
+ ask_eur=ask_eur,
82
+ offer_eur=ask_eur,
83
+ walk_away_eur=walk,
84
+ query=row.query,
85
+ status=row.status,
86
+ why=row.why,
87
+ )
88
+ )
89
+ return