walla-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,36 @@
1
+ """Session persistence (mode 0600). Never stores the password."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from walla.core.path import session_path
9
+ from walla.models.account import SessionData
10
+
11
+ __all__ = (
12
+ "clear_session",
13
+ "load_session",
14
+ "save_session",
15
+ )
16
+
17
+
18
+ def load_session(path: Path | None = None) -> SessionData | None:
19
+ p = path or session_path()
20
+ if not p.is_file():
21
+ return None
22
+ return SessionData.model_validate_json(p.read_text(encoding="utf-8"))
23
+
24
+
25
+ def save_session(session: SessionData, path: Path | None = None) -> Path:
26
+ p = path or session_path()
27
+ p.parent.mkdir(parents=True, exist_ok=True)
28
+ p.write_text(session.model_dump_json(indent=2), encoding="utf-8")
29
+ os.chmod(p, 0o600)
30
+ return p
31
+
32
+
33
+ def clear_session(path: Path | None = None) -> None:
34
+ p = path or session_path()
35
+ if p.is_file():
36
+ p.unlink()
walla/cli/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """CLI package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from walla.cli.main import app, cli
6
+
7
+ __all__ = ("app", "cli")
walla/cli/banner.py ADDED
@@ -0,0 +1,33 @@
1
+ """Terminal brand mark for ``walla --help``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.text import Text
6
+
7
+ from walla.cli.console import console
8
+
9
+ __all__ = ("print_banner",)
10
+
11
+ _BANNER_ROWS = (
12
+ r"██╗ ██╗ █████╗ ██╗ ██╗ █████╗ ██████╗ ██╗ ██╗",
13
+ r"██║ ██║ ██╔══██╗ ██║ ██║ ██╔══██╗ ██╔════╝ ██║ ██║",
14
+ r"██║ █╗ ██║ ███████║ ██║ ██║ ███████║ █████╗ ██║ ██║ ██║",
15
+ r"██║███╗██║ ██╔══██║ ██║ ██║ ██╔══██║ ╚════╝ ██║ ██║ ██║",
16
+ r"╚███╔███╔╝ ██║ ██║ ███████╗ ███████╗ ██║ ██║ ╚██████╗ ███████╗ ██║",
17
+ r" ╚══╝╚══╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝",
18
+ )
19
+
20
+ _STYLE = "bold #A3E635"
21
+ _TAG = "italic #D9F99D"
22
+ _CREDIT = "#84CC16"
23
+
24
+
25
+ def print_banner() -> None:
26
+ if not console.is_terminal:
27
+ return
28
+ console.print()
29
+ for row in _BANNER_ROWS:
30
+ console.print(Text(row, style=_STYLE))
31
+ console.print(Text(" look · offer · talk", style=_TAG))
32
+ console.print(Text(" Dr. Berend Gort · www.berendgort.dev", style=_CREDIT))
33
+ console.print()
walla/cli/catch.py ADDED
@@ -0,0 +1,43 @@
1
+ """CLI error catching and JSON envelope printing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Callable
7
+ from typing import Any, TypeVar
8
+
9
+ import typer
10
+
11
+ from walla.cli.console import console
12
+ from walla.core.envelope import error_payload, success_payload
13
+
14
+ __all__ = (
15
+ "emit",
16
+ "run_cmd",
17
+ )
18
+
19
+ F = TypeVar("F", bound=Callable[..., Any])
20
+
21
+
22
+ def emit(payload: dict[str, Any], *, as_json: bool) -> None:
23
+ if as_json:
24
+ console.print_json(json.dumps(payload, ensure_ascii=False))
25
+ else:
26
+ if payload.get("ok"):
27
+ data = payload.get("data")
28
+ console.print(data)
29
+ else:
30
+ console.print(f"[red]{payload.get('error')}[/red]")
31
+ raise typer.Exit(code=1)
32
+
33
+
34
+ def run_cmd(fn: Callable[[], Any], *, as_json: bool) -> None:
35
+ try:
36
+ data = fn()
37
+ emit(success_payload(data), as_json=as_json)
38
+ except Exception as exc: # noqa: BLE001
39
+ emit(error_payload(exc), as_json=True if as_json else as_json)
40
+ if as_json:
41
+ raise typer.Exit(code=1) from exc
42
+ console.print(f"[red]{exc}[/red]")
43
+ raise typer.Exit(code=1) from exc
@@ -0,0 +1,162 @@
1
+ """Account commands: login, inbox, say, offer, fav."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import typer
9
+
10
+ from walla.account.actions import (
11
+ add_favorite,
12
+ list_favorites,
13
+ make_offer,
14
+ quote_offer,
15
+ remove_favorite,
16
+ )
17
+ from walla.account.inbox import list_conversations, list_messages, send_message
18
+ from walla.account.login import login_cookie_text, login_cookies, whoami
19
+ from walla.account.prompt import interactive_login, session_summary
20
+ from walla.account.session_store import clear_session, load_session
21
+ from walla.cli.catch import run_cmd
22
+ from walla.core.exceptions import WallaAuthError
23
+ from walla.search.api import get_item
24
+
25
+ __all__ = ("register",)
26
+
27
+
28
+ def register(app: typer.Typer) -> None:
29
+ @app.command("login")
30
+ def login_cmd(
31
+ cookies: Path | None = typer.Option(None, "--cookies", help="cookies.txt / JSON export"),
32
+ cookie: str | None = typer.Option(None, "--cookie", help="Raw session cookie value"),
33
+ password: bool = typer.Option(
34
+ False, "--password", help="Optional email/password prompt (often fails on MFA)"
35
+ ),
36
+ json: bool = typer.Option(False, "--json"),
37
+ ) -> None:
38
+ """Log in by pasting a browser session cookie (no .env)."""
39
+
40
+ def _run() -> dict[str, Any]:
41
+ if cookies is not None:
42
+ sess = login_cookies(cookies)
43
+ elif cookie:
44
+ sess = login_cookie_text(cookie)
45
+ elif json and not password:
46
+ raise WallaAuthError(
47
+ "Non-interactive login needs --cookie '<value>' or --cookies <file>. "
48
+ "Or run without --json to paste interactively."
49
+ )
50
+ else:
51
+ sess = interactive_login(password=password)
52
+ return session_summary(sess)
53
+
54
+ run_cmd(_run, as_json=json)
55
+
56
+ @app.command("logout")
57
+ def logout_cmd(json: bool = typer.Option(False, "--json")) -> None:
58
+ def _run() -> dict[str, Any]:
59
+ clear_session()
60
+ return {"authenticated": False}
61
+
62
+ run_cmd(_run, as_json=json)
63
+
64
+ @app.command("whoami")
65
+ def whoami_cmd(json: bool = typer.Option(False, "--json")) -> None:
66
+ run_cmd(lambda: whoami(), as_json=json)
67
+
68
+ @app.command("inbox")
69
+ def inbox_cmd(json: bool = typer.Option(False, "--json")) -> None:
70
+ def _run() -> dict[str, Any]:
71
+ rows = list_conversations()
72
+ return {"conversations": [c.model_dump(mode="json") for c in rows]}
73
+
74
+ run_cmd(_run, as_json=json)
75
+
76
+ @app.command("thread")
77
+ def thread_cmd(
78
+ conversation_id: str = typer.Argument(...),
79
+ json: bool = typer.Option(False, "--json"),
80
+ ) -> None:
81
+ def _run() -> dict[str, Any]:
82
+ msgs = list_messages(conversation_id)
83
+ return {
84
+ "conversation_id": conversation_id,
85
+ "messages": [m.model_dump(mode="json") for m in msgs],
86
+ }
87
+
88
+ run_cmd(_run, as_json=json)
89
+
90
+ @app.command("say")
91
+ def say_cmd(
92
+ conversation_id: str = typer.Argument(...),
93
+ text: str = typer.Argument(...),
94
+ yes: bool = typer.Option(False, "--yes"),
95
+ json: bool = typer.Option(False, "--json"),
96
+ ) -> None:
97
+ def _run() -> dict[str, Any]:
98
+ return send_message(conversation_id, text, confirm=yes)
99
+
100
+ run_cmd(_run, as_json=json)
101
+
102
+ @app.command("offer")
103
+ def offer_cmd(
104
+ item_id: str = typer.Argument(...),
105
+ eur: float = typer.Option(..., "--eur"),
106
+ yes: bool = typer.Option(False, "--yes"),
107
+ json: bool = typer.Option(False, "--json"),
108
+ ) -> None:
109
+ def _run() -> dict[str, Any]:
110
+ if not yes:
111
+ item = get_item(item_id)
112
+ quote = quote_offer(item, eur)
113
+ return {
114
+ "needs_confirm": True,
115
+ "title": item.title,
116
+ "url": item.url,
117
+ "quote": quote.model_dump(mode="json"),
118
+ "hint": "Re-run with --yes to send",
119
+ }
120
+ return make_offer(item_id, eur, confirm=True)
121
+
122
+ run_cmd(_run, as_json=json)
123
+
124
+ fav_app = typer.Typer(help="Favorites")
125
+ app.add_typer(fav_app, name="fav")
126
+
127
+ @fav_app.command("list")
128
+ def fav_list(json: bool = typer.Option(False, "--json")) -> None:
129
+ def _run() -> dict[str, Any]:
130
+ return {"favorites": list_favorites()}
131
+
132
+ run_cmd(_run, as_json=json)
133
+
134
+ @fav_app.command("add")
135
+ def fav_add(
136
+ item_id: str = typer.Argument(...),
137
+ json: bool = typer.Option(False, "--json"),
138
+ ) -> None:
139
+ run_cmd(lambda: add_favorite(item_id), as_json=json)
140
+
141
+ @fav_app.command("rm")
142
+ def fav_rm(
143
+ item_id: str = typer.Argument(...),
144
+ json: bool = typer.Option(False, "--json"),
145
+ ) -> None:
146
+ run_cmd(lambda: remove_favorite(item_id), as_json=json)
147
+
148
+ @app.command("session")
149
+ def session_cmd(json: bool = typer.Option(False, "--json")) -> None:
150
+ def _run() -> dict[str, Any]:
151
+ sess = load_session()
152
+ if not sess:
153
+ return {"authenticated": False}
154
+ return {
155
+ "authenticated": True,
156
+ "has_access_token": bool(sess.access_token),
157
+ "has_cookie": bool(sess.session_cookie),
158
+ "user_id": sess.user_id,
159
+ "micro_name": sess.micro_name,
160
+ }
161
+
162
+ run_cmd(_run, as_json=json)
walla/cli/cmd_look.py ADDED
@@ -0,0 +1,143 @@
1
+ """Look commands: search, item, categories, watch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from typing import Any
7
+
8
+ import typer
9
+
10
+ from walla.cli.catch import run_cmd
11
+ from walla.hunter.profile_store import load_profile
12
+ from walla.hunter.verdict import score_listing
13
+ from walla.hunter.watches import (
14
+ Watch,
15
+ diff_new_ids,
16
+ load_watches,
17
+ merge_seen,
18
+ save_watches,
19
+ )
20
+ from walla.search.api import get_categories, get_item, search_listings
21
+
22
+ __all__ = ("register",)
23
+
24
+
25
+ def register(app: typer.Typer) -> None:
26
+ @app.command("search")
27
+ def search(
28
+ keywords: str = typer.Argument(...),
29
+ max_results: int = typer.Option(40, "--max"),
30
+ min_price: float | None = typer.Option(None, "--min"),
31
+ max_price: float | None = typer.Option(None, "--max-price"),
32
+ category_id: int | None = typer.Option(None, "--category"),
33
+ order_by: str = typer.Option("most_relevance", "--order"),
34
+ json: bool = typer.Option(False, "--json"),
35
+ ) -> None:
36
+ def _run() -> dict[str, Any]:
37
+ profile = load_profile()
38
+ if not profile.ready:
39
+ return {
40
+ "ready": False,
41
+ "intake": {"prompt_to_user": profile.intake_prompt()},
42
+ }
43
+ result = search_listings(
44
+ keywords,
45
+ latitude=float(profile.lat or 0),
46
+ longitude=float(profile.lon or 0),
47
+ max_results=max_results,
48
+ min_price=min_price,
49
+ max_price=max_price or profile.budget,
50
+ category_id=category_id,
51
+ order_by=order_by,
52
+ )
53
+ rows = []
54
+ for listing in result.listings:
55
+ listing.verdict = score_listing(listing, profile)
56
+ rows.append(listing.model_dump(mode="json"))
57
+ return {
58
+ "count": len(rows),
59
+ "next_page": result.next_page,
60
+ "listings": rows,
61
+ }
62
+
63
+ run_cmd(_run, as_json=json)
64
+
65
+ @app.command("item")
66
+ def item_cmd(
67
+ item_id: str = typer.Argument(...),
68
+ json: bool = typer.Option(False, "--json"),
69
+ ) -> None:
70
+ def _run() -> dict[str, Any]:
71
+ listing = get_item(item_id)
72
+ profile = load_profile()
73
+ listing.verdict = score_listing(listing, profile)
74
+ return listing.model_dump(mode="json")
75
+
76
+ run_cmd(_run, as_json=json)
77
+
78
+ @app.command("categories")
79
+ def categories_cmd(json: bool = typer.Option(False, "--json")) -> None:
80
+ def _run() -> dict[str, Any]:
81
+ cats = get_categories()
82
+ return {"categories": [c.model_dump(mode="json") for c in cats]}
83
+
84
+ run_cmd(_run, as_json=json)
85
+
86
+ watch_app = typer.Typer(help="Local saved searches")
87
+ app.add_typer(watch_app, name="watch")
88
+
89
+ @watch_app.command("add")
90
+ def watch_add(
91
+ keywords: str = typer.Argument(...),
92
+ max_price: float | None = typer.Option(None, "--max-price"),
93
+ json: bool = typer.Option(False, "--json"),
94
+ ) -> None:
95
+ def _run() -> dict[str, Any]:
96
+ store = load_watches()
97
+ w = Watch(id=str(uuid.uuid4())[:8], keywords=keywords, max_price=max_price)
98
+ store.watches.append(w)
99
+ save_watches(store)
100
+ return w.model_dump(mode="json")
101
+
102
+ run_cmd(_run, as_json=json)
103
+
104
+ @watch_app.command("list")
105
+ def watch_list(json: bool = typer.Option(False, "--json")) -> None:
106
+ def _run() -> dict[str, Any]:
107
+ store = load_watches()
108
+ return {"watches": [w.model_dump(mode="json") for w in store.watches]}
109
+
110
+ run_cmd(_run, as_json=json)
111
+
112
+ @watch_app.command("check")
113
+ def watch_check(
114
+ watch_id: str = typer.Argument(...),
115
+ json: bool = typer.Option(False, "--json"),
116
+ ) -> None:
117
+ def _run() -> dict[str, Any]:
118
+ store = load_watches()
119
+ watch = next((w for w in store.watches if w.id == watch_id), None)
120
+ if watch is None:
121
+ raise ValueError(f"unknown watch {watch_id}")
122
+ profile = load_profile()
123
+ if not profile.ready:
124
+ raise ValueError("profile not ready; run walla setup")
125
+ result = search_listings(
126
+ watch.keywords,
127
+ latitude=float(profile.lat or 0),
128
+ longitude=float(profile.lon or 0),
129
+ max_results=40,
130
+ max_price=watch.max_price,
131
+ )
132
+ found = [L.id for L in result.listings]
133
+ new_ids = diff_new_ids(watch.seen_ids, found)
134
+ store.watches = [
135
+ merge_seen(w, found) if w.id == watch_id else w for w in store.watches
136
+ ]
137
+ save_watches(store)
138
+ new_listings = [
139
+ L.model_dump(mode="json") for L in result.listings if L.id in set(new_ids)
140
+ ]
141
+ return {"watch_id": watch_id, "new_ids": new_ids, "listings": new_listings}
142
+
143
+ run_cmd(_run, as_json=json)
walla/cli/cmd_ops.py ADDED
@@ -0,0 +1,100 @@
1
+ """Ops commands: doctor, instruct, setup, profile."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import typer
8
+
9
+ from walla import __version__
10
+ from walla.account.session_store import load_session
11
+ from walla.cli.catch import run_cmd
12
+ from walla.core.instruct import instruct_recipe
13
+ from walla.hunter.profile_store import load_profile, parse_intake, save_profile
14
+
15
+ __all__ = ("register",)
16
+
17
+
18
+ def register(app: typer.Typer) -> None:
19
+ @app.command("instruct")
20
+ def instruct(json: bool = typer.Option(False, "--json")) -> None:
21
+ """Agent recipe."""
22
+ run_cmd(instruct_recipe, as_json=json)
23
+
24
+ @app.command("doctor")
25
+ def doctor(json: bool = typer.Option(False, "--json")) -> None:
26
+ """Network + session (never prints secrets)."""
27
+
28
+ def _run() -> dict[str, Any]:
29
+ sess = load_session()
30
+ profile = load_profile()
31
+ reachable = False
32
+ try:
33
+ from walla.http.client import HttpClient
34
+
35
+ HttpClient().get("/api/v3/categories", use_cache=False)
36
+ reachable = True
37
+ except Exception: # noqa: BLE001
38
+ reachable = False
39
+ has_session = bool(sess and (sess.access_token or sess.session_cookie))
40
+ return {
41
+ "version": __version__,
42
+ "network": {"reachable": reachable},
43
+ "auth": {
44
+ "session": has_session,
45
+ "how": "walla login (paste browser session cookie)",
46
+ },
47
+ "profile": {"ready": profile.ready, "label": profile.label},
48
+ }
49
+
50
+ run_cmd(_run, as_json=json)
51
+
52
+ @app.command("setup")
53
+ def setup(
54
+ lat: float | None = typer.Option(None),
55
+ lon: float | None = typer.Option(None),
56
+ km: float = typer.Option(30.0),
57
+ pickup_km: float = typer.Option(30.0, "--pickup-km"),
58
+ label: str | None = typer.Option(None),
59
+ budget: float | None = typer.Option(None),
60
+ intake: str | None = typer.Option(None, "--intake"),
61
+ json: bool = typer.Option(False, "--json"),
62
+ ) -> None:
63
+ """Save home point + radius."""
64
+
65
+ def _run() -> dict[str, Any]:
66
+ if intake:
67
+ profile = parse_intake(intake)
68
+ else:
69
+ profile = load_profile()
70
+ if lat is not None:
71
+ profile.lat = lat
72
+ if lon is not None:
73
+ profile.lon = lon
74
+ profile.km = km
75
+ profile.pickup_km = pickup_km
76
+ if label is not None:
77
+ profile.label = label
78
+ if budget is not None:
79
+ profile.budget = budget
80
+ if not profile.ready:
81
+ return {
82
+ "ready": False,
83
+ "intake": {"prompt_to_user": profile.intake_prompt()},
84
+ }
85
+ save_profile(profile)
86
+ return {"ready": True, "profile": profile.model_dump(mode="json")}
87
+
88
+ run_cmd(_run, as_json=json)
89
+
90
+ @app.command("profile")
91
+ def profile_cmd(json: bool = typer.Option(False, "--json")) -> None:
92
+ def _run() -> dict[str, Any]:
93
+ p = load_profile()
94
+ return {
95
+ "ready": p.ready,
96
+ "profile": p.model_dump(mode="json"),
97
+ "intake": None if p.ready else {"prompt_to_user": p.intake_prompt()},
98
+ }
99
+
100
+ run_cmd(_run, as_json=json)
walla/cli/console.py ADDED
@@ -0,0 +1,9 @@
1
+ """Rich console for TTY output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console
6
+
7
+ __all__ = ("console",)
8
+
9
+ console = Console()
walla/cli/main.py ADDED
@@ -0,0 +1,44 @@
1
+ """Typer CLI -- agent-friendly Wallapop client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import typer
8
+ from typer.core import TyperGroup
9
+
10
+ from walla.cli import cmd_account, cmd_look, cmd_ops
11
+ from walla.cli.banner import print_banner
12
+
13
+ __all__ = ("app", "cli")
14
+
15
+
16
+ class _WallaGroup(TyperGroup):
17
+ def format_help(self, ctx: Any, formatter: Any) -> None:
18
+ print_banner()
19
+ super().format_help(ctx, formatter)
20
+
21
+
22
+ app = typer.Typer(
23
+ name="walla",
24
+ cls=_WallaGroup,
25
+ help=(
26
+ "Wallapop.es look · offer · talk for humans and agents. "
27
+ "Happy path: setup -> search -> login -> inbox / offer. "
28
+ "Run `walla instruct --json` for the recipe."
29
+ ),
30
+ no_args_is_help=True,
31
+ add_completion=False,
32
+ )
33
+
34
+ cmd_ops.register(app)
35
+ cmd_look.register(app)
36
+ cmd_account.register(app)
37
+
38
+
39
+ def cli() -> None:
40
+ app()
41
+
42
+
43
+ if __name__ == "__main__":
44
+ cli()
walla/core/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Core package facade."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from walla.core.envelope import error_payload, success_payload
6
+ from walla.core.exceptions import WallaError
7
+
8
+ __all__ = (
9
+ "WallaError",
10
+ "error_payload",
11
+ "success_payload",
12
+ )
walla/core/envelope.py ADDED
@@ -0,0 +1,46 @@
1
+ """Shared JSON envelope for CLI and MCP (agent contract)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from walla.core.errors import classify_error
8
+ from walla.core.exceptions import WallaAmbiguousError, WallaAuthError
9
+
10
+ __all__ = (
11
+ "API_VERSION",
12
+ "dump_model",
13
+ "error_payload",
14
+ "success_payload",
15
+ )
16
+
17
+ API_VERSION = 1
18
+
19
+
20
+ def success_payload(data: Any) -> dict[str, Any]:
21
+ return {"ok": True, "api_version": API_VERSION, "data": data}
22
+
23
+
24
+ def error_payload(exc: BaseException) -> dict[str, Any]:
25
+ classified = classify_error(exc)
26
+ payload: dict[str, Any] = {
27
+ "ok": False,
28
+ "api_version": API_VERSION,
29
+ "error": str(exc),
30
+ **classified.as_fields(),
31
+ }
32
+ if isinstance(exc, WallaAmbiguousError):
33
+ payload["candidates"] = [
34
+ c.model_dump(mode="json") if hasattr(c, "model_dump") else c
35
+ for c in exc.candidates
36
+ ]
37
+ if isinstance(exc, WallaAuthError):
38
+ from walla.core.human_fix import cookie_export_fix
39
+
40
+ payload["human_fix"] = cookie_export_fix()
41
+ return payload
42
+
43
+
44
+ def dump_model(model: Any) -> Any:
45
+ """Pydantic model to JSON-ready dict."""
46
+ return model.model_dump(mode="json")