walla-cli 0.1.11__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.11 → walla_cli-0.1.12}/PKG-INFO +1 -1
  2. {walla_cli-0.1.11 → walla_cli-0.1.12}/docs/WIRE.md +2 -1
  3. {walla_cli-0.1.11 → walla_cli-0.1.12}/pyproject.toml +1 -1
  4. {walla_cli-0.1.11 → walla_cli-0.1.12}/skills/walla-sell/SKILL.md +3 -2
  5. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_sell.py +57 -0
  6. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/__init__.py +1 -1
  7. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/sell.py +58 -50
  8. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/sell_suggest.py +1 -1
  9. walla_cli-0.1.12/walla/account/sell_upload.py +51 -0
  10. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/cli/cmd_sell.py +53 -18
  11. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/core/instruct.py +1 -1
  12. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/search/category_find.py +4 -4
  13. {walla_cli-0.1.11 → walla_cli-0.1.12}/.cursor/rules/quality.mdc +0 -0
  14. {walla_cli-0.1.11 → walla_cli-0.1.12}/.github/workflows/ci.yml +0 -0
  15. {walla_cli-0.1.11 → walla_cli-0.1.12}/.gitignore +0 -0
  16. {walla_cli-0.1.11 → walla_cli-0.1.12}/AGENTS.md +0 -0
  17. {walla_cli-0.1.11 → walla_cli-0.1.12}/LICENSE +0 -0
  18. {walla_cli-0.1.11 → walla_cli-0.1.12}/README.md +0 -0
  19. {walla_cli-0.1.11 → walla_cli-0.1.12}/docs/assets/walla-demo-poster.jpg +0 -0
  20. {walla_cli-0.1.11 → walla_cli-0.1.12}/docs/assets/walla-demo-preview.gif +0 -0
  21. {walla_cli-0.1.11 → walla_cli-0.1.12}/docs/assets/walla-demo.mp4 +0 -0
  22. {walla_cli-0.1.11 → walla_cli-0.1.12}/docs/assets/walla-github-banner.jpg +0 -0
  23. {walla_cli-0.1.11 → walla_cli-0.1.12}/docs/code_quality.md +0 -0
  24. {walla_cli-0.1.11 → walla_cli-0.1.12}/docs/data_engineering_standards.md +0 -0
  25. {walla_cli-0.1.11 → walla_cli-0.1.12}/docs/mcp.md +0 -0
  26. {walla_cli-0.1.11 → walla_cli-0.1.12}/docs/objective_function.md +0 -0
  27. {walla_cli-0.1.11 → walla_cli-0.1.12}/docs/rate_limit_analysis.md +0 -0
  28. {walla_cli-0.1.11 → walla_cli-0.1.12}/fixtures/categories.json +0 -0
  29. {walla_cli-0.1.11 → walla_cli-0.1.12}/fixtures/item_detail.json +0 -0
  30. {walla_cli-0.1.11 → walla_cli-0.1.12}/fixtures/offer_buyer_request.json +0 -0
  31. {walla_cli-0.1.11 → walla_cli-0.1.12}/fixtures/search_bicicleta.json +0 -0
  32. {walla_cli-0.1.11 → walla_cli-0.1.12}/fixtures/sell_item_request.json +0 -0
  33. {walla_cli-0.1.11 → walla_cli-0.1.12}/fixtures/sell_suggest_draft.json +0 -0
  34. {walla_cli-0.1.11 → walla_cli-0.1.12}/fixtures/wire_contracts.json +0 -0
  35. {walla_cli-0.1.11 → walla_cli-0.1.12}/scripts/check_code_quality.py +0 -0
  36. {walla_cli-0.1.11 → walla_cli-0.1.12}/scripts/dogfood_run.py +0 -0
  37. {walla_cli-0.1.11 → walla_cli-0.1.12}/scripts/probe_rate.py +0 -0
  38. {walla_cli-0.1.11 → walla_cli-0.1.12}/skills/walla/SKILL.md +0 -0
  39. {walla_cli-0.1.11 → walla_cli-0.1.12}/skills/walla-buy/SKILL.md +0 -0
  40. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_account.py +0 -0
  41. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_account_more.py +0 -0
  42. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_catch.py +0 -0
  43. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_categories_find.py +0 -0
  44. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_chat_open.py +0 -0
  45. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_cli_json.py +0 -0
  46. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_core.py +0 -0
  47. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_desk.py +0 -0
  48. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_edges.py +0 -0
  49. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_export.py +0 -0
  50. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_http_more.py +0 -0
  51. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_live.py +0 -0
  52. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_negotiate.py +0 -0
  53. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_parsers.py +0 -0
  54. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_polite.py +0 -0
  55. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_regressions.py +0 -0
  56. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_search_api.py +0 -0
  57. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_session_store.py +0 -0
  58. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_shortlist.py +0 -0
  59. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_suggest_watch.py +0 -0
  60. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_verdict.py +0 -0
  61. {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_wire_contracts.py +0 -0
  62. {walla_cli-0.1.11 → walla_cli-0.1.12}/uv.lock +0 -0
  63. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/__init__.py +0 -0
  64. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/actions.py +0 -0
  65. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/chat.py +0 -0
  66. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/cookies.py +0 -0
  67. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/desk.py +0 -0
  68. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/desk_watch.py +0 -0
  69. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/inbox.py +0 -0
  70. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/login.py +0 -0
  71. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/offer_wire.py +0 -0
  72. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/prompt.py +0 -0
  73. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/sell_steps.py +0 -0
  74. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/sell_wire.py +0 -0
  75. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/session_store.py +0 -0
  76. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/cli/__init__.py +0 -0
  77. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/cli/banner.py +0 -0
  78. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/cli/catch.py +0 -0
  79. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/cli/cmd_account.py +0 -0
  80. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/cli/cmd_desk.py +0 -0
  81. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/cli/cmd_look.py +0 -0
  82. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/cli/cmd_ops.py +0 -0
  83. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/cli/console.py +0 -0
  84. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/cli/main.py +0 -0
  85. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/core/__init__.py +0 -0
  86. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/core/envelope.py +0 -0
  87. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/core/errors.py +0 -0
  88. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/core/exceptions.py +0 -0
  89. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/core/human_fix.py +0 -0
  90. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/core/path.py +0 -0
  91. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/http/__init__.py +0 -0
  92. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/http/client.py +0 -0
  93. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/http/headers.py +0 -0
  94. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/http/polite.py +0 -0
  95. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/hunter/__init__.py +0 -0
  96. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/hunter/converge.py +0 -0
  97. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/hunter/export.py +0 -0
  98. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/hunter/export_pdf.py +0 -0
  99. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/hunter/negotiate.py +0 -0
  100. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/hunter/profile_store.py +0 -0
  101. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/hunter/pursuits.py +0 -0
  102. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/hunter/shortlist.py +0 -0
  103. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/hunter/stack.py +0 -0
  104. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/hunter/verdict.py +0 -0
  105. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/hunter/watches.py +0 -0
  106. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/mcp/__init__.py +0 -0
  107. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/mcp/_entry.py +0 -0
  108. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/mcp/server.py +0 -0
  109. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/models/__init__.py +0 -0
  110. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/models/account.py +0 -0
  111. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/models/listing.py +0 -0
  112. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/models/profile.py +0 -0
  113. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/search/__init__.py +0 -0
  114. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/search/api.py +0 -0
  115. {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/search/parse.py +0 -0
  116. {walla_cli-0.1.11 → 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.11
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
@@ -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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "walla-cli"
3
- version = "0.1.11"
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"
@@ -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 \
@@ -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
 
@@ -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.12"
@@ -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)
@@ -6,13 +6,13 @@ import uuid
6
6
  from pathlib import Path
7
7
  from typing import Any
8
8
 
9
- from walla.account.sell import upload_pictures
10
9
  from walla.account.sell_steps import (
11
10
  auth_client,
12
11
  parse_steps_draft,
13
12
  poll_suggested,
14
13
  post_step,
15
14
  )
15
+ from walla.account.sell_upload import upload_pictures
16
16
  from walla.search.api import get_categories
17
17
  from walla.search.category_find import flatten_categories, resolve_root_id
18
18
 
@@ -0,0 +1,51 @@
1
+ """Multipart picture upload for Vender (/api/v3/upload/{id}/pictures)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from curl_cffi import CurlMime, requests
9
+
10
+ from walla.account.sell_steps import auth_headers
11
+ from walla.core.exceptions import WallaHTTPError
12
+ from walla.http.headers import API_BASE
13
+ from walla.http.polite import wait_turn
14
+
15
+ __all__ = ("IMAGE_TYPES", "upload_pictures")
16
+
17
+ IMAGE_TYPES = {
18
+ ".jpg": "image/jpeg",
19
+ ".jpeg": "image/jpeg",
20
+ ".png": "image/png",
21
+ ".webp": "image/webp",
22
+ }
23
+
24
+
25
+ def upload_pictures(upload_id: str, photos: list[Path]) -> int:
26
+ """POST each photo to /api/v3/upload/{id}/pictures (HTTP 204)."""
27
+ if not photos:
28
+ raise ValueError("at least one photo required")
29
+ headers = {k: v for k, v in auth_headers().items() if k.lower() != "content-type"}
30
+ session: Any = requests.Session()
31
+ for path in photos:
32
+ data = path.read_bytes()
33
+ ctype = IMAGE_TYPES.get(path.suffix.lower(), "image/jpeg")
34
+ wait_turn()
35
+ mp = CurlMime()
36
+ mp.addpart(name="file", content_type=ctype, filename=path.name, data=data)
37
+ resp: Any = session.request(
38
+ "POST",
39
+ f"{API_BASE}/api/v3/upload/{upload_id}/pictures",
40
+ headers=headers,
41
+ multipart=mp,
42
+ impersonate="chrome",
43
+ timeout=60,
44
+ )
45
+ mp.close()
46
+ if resp.status_code not in (200, 201, 204):
47
+ raise WallaHTTPError(
48
+ f"picture upload HTTP {resp.status_code}: {(resp.text or '')[:200]}",
49
+ status_code=resp.status_code,
50
+ )
51
+ return len(photos)
@@ -7,7 +7,7 @@ from typing import Any
7
7
 
8
8
  import typer
9
9
 
10
- from walla.account.sell import delete_listing, publish_listing
10
+ from walla.account.sell import delete_listing, publish_listing, publish_prepared
11
11
  from walla.account.sell_suggest import suggest_from_photos
12
12
  from walla.account.sell_wire import CONDITIONS, SELL_QUESTIONS, missing_sell_fields
13
13
  from walla.cli.catch import run_cmd
@@ -41,7 +41,7 @@ def register(app: typer.Typer) -> None:
41
41
  suggest: bool = typer.Option(
42
42
  False,
43
43
  "--suggest",
44
- help="Prefill category via Wallapop steps (needs --title)",
44
+ help="Prefill category via Wallapop steps (needs --title; uploads photos)",
45
45
  ),
46
46
  yes: bool = typer.Option(False, "--yes", help="Publish for real"),
47
47
  json: bool = typer.Option(False, "--json"),
@@ -58,8 +58,7 @@ def register(app: typer.Typer) -> None:
58
58
  root_id = root
59
59
  picked = None
60
60
  suggested = None
61
- auto = bool(title) and not category and not yes
62
- if suggest or auto:
61
+ if suggest:
63
62
  if not title:
64
63
  raise ValueError("--suggest needs --title")
65
64
  suggested = suggest_from_photos(
@@ -106,7 +105,7 @@ def register(app: typer.Typer) -> None:
106
105
  },
107
106
  "hint": (
108
107
  "Fill missing fields, then walla sell <photos> … --yes. "
109
- "walla sell … --title … --suggest prefills category from Wallapop."
108
+ "Only --suggest uploads photos to prefill category."
110
109
  ),
111
110
  }
112
111
  if picked is not None:
@@ -126,21 +125,57 @@ def register(app: typer.Typer) -> None:
126
125
  )
127
126
  if profile.lat is None or profile.lon is None:
128
127
  raise ValueError("Set location first: walla setup --lat … --lon …")
129
- assert title_use and description and eur is not None and leaf and root_id
128
+ if not title_use or not description or eur is None or not leaf or not root_id:
129
+ raise ValueError(
130
+ "Cannot publish; need title, description, eur, category leaf, and root"
131
+ )
130
132
  lowest = min(floor, eur) if floor is not None else eur
131
- created = publish_listing(
132
- paths,
133
- title=title_use,
134
- description=description,
135
- price_eur=eur,
136
- category_leaf_id=str(leaf),
137
- root_category_id=str(root_id),
138
- lat=profile.lat,
139
- lon=profile.lon,
140
- condition=condition,
141
- shipping=ship,
142
- weight_kg=weight_kg,
133
+ sug_leaf = (
134
+ str(suggested.get("category_leaf_id") or "") if suggested else ""
135
+ )
136
+ reuse_id = (
137
+ str(suggested["upload_id"])
138
+ if (
139
+ suggested
140
+ and suggested.get("upload_id")
141
+ and sug_leaf
142
+ and sug_leaf == str(leaf)
143
+ )
144
+ else ""
143
145
  )
146
+ if reuse_id:
147
+ created = publish_prepared(
148
+ paths,
149
+ upload_id=reuse_id,
150
+ title=title_use,
151
+ description=description,
152
+ price_eur=eur,
153
+ category_leaf_id=str(leaf),
154
+ lat=profile.lat,
155
+ lon=profile.lon,
156
+ condition=condition,
157
+ shipping=ship,
158
+ weight_kg=weight_kg,
159
+ wire_draft={
160
+ "title": title_use,
161
+ "category_leaf_id": str(leaf),
162
+ "root_category_id": str(root_id),
163
+ },
164
+ )
165
+ else:
166
+ created = publish_listing(
167
+ paths,
168
+ title=title_use,
169
+ description=description,
170
+ price_eur=eur,
171
+ category_leaf_id=str(leaf),
172
+ root_category_id=str(root_id),
173
+ lat=profile.lat,
174
+ lon=profile.lon,
175
+ condition=condition,
176
+ shipping=ship,
177
+ weight_kg=weight_kg,
178
+ )
144
179
  remember(
145
180
  Pursuit(
146
181
  item_id=str(created["id"]),
@@ -147,7 +147,7 @@ def instruct_recipe() -> dict[str, Any]:
147
147
  "desk --watch polls until stack.best converges; still never pay.",
148
148
  "Bare say, offer, and sell still need --yes. Never pay or accept in walla.",
149
149
  "Sell: drop photos, ask data.questions, then publish with --yes only.",
150
- "Sell --suggest prefills category from Wallapop steps when title is set.",
150
+ "Sell --suggest uploads photos once to prefill category; draft without it is local.",
151
151
  "If only asked to look, do not send.",
152
152
  "Ambiguous match or multiple GRABs: ask once with candidates.",
153
153
  "Default search is all of Spain. Shipping is enough; do not fence on profile.km.",
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from dataclasses import dataclass
5
+ from pydantic import BaseModel, ConfigDict
6
6
 
7
7
  from walla.models.profile import Category
8
8
 
@@ -14,8 +14,9 @@ __all__ = (
14
14
  )
15
15
 
16
16
 
17
- @dataclass(frozen=True)
18
- class CategoryHit:
17
+ class CategoryHit(BaseModel):
18
+ model_config = ConfigDict(frozen=True)
19
+
19
20
  leaf_id: str
20
21
  root_id: str
21
22
  path: str
@@ -70,7 +71,6 @@ def find_categories(roots: list[Category], query: str) -> list[CategoryHit]:
70
71
  elif name_l.startswith(tokens[-1]) or tokens[-1].startswith(_stem(name_l)):
71
72
  score += 25
72
73
  score += sum(6 for tok in tokens if tok in name_l)
73
- # Prefer the named leaf over a shallow cousin that only contains the token.
74
74
  if any(_stem(part) == _stem(tokens[-1]) for part in blob.split(" / ")):
75
75
  score += 15
76
76
  scored.append((score, hit))
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes