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.
walla/core/errors.py ADDED
@@ -0,0 +1,90 @@
1
+ """Shared error classification for CLI and MCP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from walla.core.exceptions import (
9
+ WallaAmbiguousError,
10
+ WallaAuthError,
11
+ WallaError,
12
+ WallaForbiddenError,
13
+ WallaHTTPError,
14
+ WallaNotFoundError,
15
+ WallaParseError,
16
+ WallaRateLimitError,
17
+ WallaUnsupportedError,
18
+ )
19
+
20
+ __all__ = (
21
+ "ErrorClassification",
22
+ "classify_error",
23
+ )
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ErrorClassification:
28
+ error_type: str
29
+ retryable: bool
30
+ http_status: int | None = None
31
+ retry_after_s: float | None = None
32
+
33
+ def as_fields(self) -> dict[str, Any]:
34
+ fields: dict[str, Any] = {
35
+ "error_type": self.error_type,
36
+ "retryable": self.retryable,
37
+ }
38
+ if self.http_status is not None:
39
+ fields["http_status"] = self.http_status
40
+ if self.retry_after_s is not None:
41
+ fields["retry_after_s"] = self.retry_after_s
42
+ return fields
43
+
44
+
45
+ def classify_error(exc: BaseException) -> ErrorClassification:
46
+ """Map an exception to a stable ``error_type`` + ``retryable`` pair."""
47
+ cause = getattr(exc, "last_attempt", None)
48
+ if cause is not None:
49
+ try:
50
+ inner = cause.exception()
51
+ if inner is not None:
52
+ return classify_error(inner)
53
+ except Exception: # noqa: BLE001
54
+ pass
55
+ if isinstance(exc, WallaAmbiguousError):
56
+ return ErrorClassification("ambiguous", retryable=False)
57
+ if isinstance(exc, WallaAuthError):
58
+ return ErrorClassification("auth", retryable=False)
59
+ if isinstance(exc, WallaUnsupportedError):
60
+ return ErrorClassification("unsupported", retryable=False)
61
+ if isinstance(exc, WallaNotFoundError):
62
+ return ErrorClassification("not_found", retryable=False)
63
+ if isinstance(exc, WallaParseError):
64
+ return ErrorClassification("parse_error", retryable=False)
65
+ if isinstance(exc, WallaRateLimitError):
66
+ return ErrorClassification(
67
+ "rate_limited",
68
+ retryable=True,
69
+ http_status=exc.status_code,
70
+ retry_after_s=exc.retry_after_s,
71
+ )
72
+ if isinstance(exc, WallaForbiddenError):
73
+ return ErrorClassification(
74
+ "forbidden", retryable=False, http_status=exc.status_code
75
+ )
76
+ if isinstance(exc, WallaHTTPError):
77
+ status = getattr(exc, "status_code", None)
78
+ retryable = status is not None and (status == 429 or status >= 500)
79
+ return ErrorClassification(
80
+ "http_error", retryable=retryable, http_status=status
81
+ )
82
+ if isinstance(exc, TimeoutError):
83
+ return ErrorClassification("timeout", retryable=True)
84
+ if isinstance(exc, ConnectionError | OSError):
85
+ return ErrorClassification("connection_error", retryable=True)
86
+ if isinstance(exc, ValueError):
87
+ return ErrorClassification("validation_error", retryable=False)
88
+ if isinstance(exc, WallaError):
89
+ return ErrorClassification("walla_error", retryable=False)
90
+ return ErrorClassification("unexpected_error", retryable=False)
@@ -0,0 +1,67 @@
1
+ """Shared walla error types (foundation -- importable by http + core)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ __all__ = (
8
+ "WallaAmbiguousError",
9
+ "WallaAuthError",
10
+ "WallaError",
11
+ "WallaForbiddenError",
12
+ "WallaHTTPError",
13
+ "WallaNotFoundError",
14
+ "WallaParseError",
15
+ "WallaRateLimitError",
16
+ "WallaUnsupportedError",
17
+ )
18
+
19
+
20
+ class WallaError(Exception):
21
+ """Base."""
22
+
23
+
24
+ class WallaHTTPError(WallaError):
25
+ def __init__(self, message: str, *, status_code: int | None = None) -> None:
26
+ super().__init__(message)
27
+ self.status_code = status_code
28
+
29
+
30
+ class WallaRateLimitError(WallaHTTPError):
31
+ def __init__(
32
+ self,
33
+ message: str,
34
+ *,
35
+ status_code: int = 429,
36
+ retry_after_s: float | None = None,
37
+ ) -> None:
38
+ super().__init__(message, status_code=status_code)
39
+ self.retry_after_s = retry_after_s
40
+
41
+
42
+ class WallaAuthError(WallaError):
43
+ """Missing or rejected credentials / session."""
44
+
45
+
46
+ class WallaForbiddenError(WallaHTTPError):
47
+ """Hard stop (403 circuit)."""
48
+
49
+
50
+ class WallaParseError(WallaError):
51
+ pass
52
+
53
+
54
+ class WallaNotFoundError(WallaError):
55
+ pass
56
+
57
+
58
+ class WallaUnsupportedError(WallaError):
59
+ """Wire shape not captured yet."""
60
+
61
+
62
+ class WallaAmbiguousError(WallaError):
63
+ """Multiple listings matched; caller must pick an id."""
64
+
65
+ def __init__(self, message: str, *, candidates: list[Any]) -> None:
66
+ super().__init__(message)
67
+ self.candidates = candidates
@@ -0,0 +1,26 @@
1
+ """User-visible fix recipes (not system prompts)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ __all__ = (
8
+ "cookie_export_fix",
9
+ )
10
+
11
+
12
+ def cookie_export_fix() -> dict[str, Any]:
13
+ return {
14
+ "title": "Paste Wallapop session cookie",
15
+ "steps": [
16
+ "Log in at https://es.wallapop.com in your browser.",
17
+ "Copy cookie __Secure-next-auth.session-token (Cookie-Editor).",
18
+ "Run: walla login",
19
+ "Paste the cookie value when prompted.",
20
+ "Agents: walla login --cookie '<value>' --json",
21
+ ],
22
+ "say_to_user": (
23
+ "Run `walla login` and paste your "
24
+ "__Secure-next-auth.session-token cookie from es.wallapop.com."
25
+ ),
26
+ }
walla/core/instruct.py ADDED
@@ -0,0 +1,50 @@
1
+ """Agent recipe returned by ``walla instruct --json``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ __all__ = ("instruct_recipe",)
8
+
9
+
10
+ def instruct_recipe() -> dict[str, Any]:
11
+ return {
12
+ "name": "walla",
13
+ "tagline": "look · offer · talk",
14
+ "example": (
15
+ "Human: find a used kite near Barcelona under 400eur, message "
16
+ "seller, offer if GRAB. Agent: walla search -> verdict + item URL "
17
+ "-> ask confirm -> walla say --yes -> walla offer --eur N --yes "
18
+ "-> walla inbox."
19
+ ),
20
+ "protocol": [
21
+ "Install: pipx install 'walla-cli[mcp]' (or pipx install -e '.[mcp]' in the clone).",
22
+ "walla doctor --json - never print tokens or cookies.",
23
+ "If no session: ask the human to run `walla login` and paste "
24
+ "__Secure-next-auth.session-token (or pass --cookie for non-interactive).",
25
+ "Do the ask with walla … --json and narrate in prose.",
26
+ ],
27
+ "verbs": {
28
+ "look": ["walla search <q> --json", "walla item <id> --json"],
29
+ "talk": [
30
+ "walla inbox --json",
31
+ "walla thread <id> --json",
32
+ "walla say <id> <text> --yes",
33
+ ],
34
+ "offer": ["walla offer <id> --eur <n> --yes"],
35
+ },
36
+ "rules": [
37
+ "search / item / inbox are safe reads.",
38
+ "say and offer require --yes / confirm=true; name listing + euros.",
39
+ "If only asked to look, do not send.",
40
+ "Ambiguous match: ask once with candidates.",
41
+ "Refuse in-person offers outside pickup radius.",
42
+ "No .env credentials. Login is cookie paste only (optional --password).",
43
+ ],
44
+ "envelope": ["ok", "api_version", "data | error / error_type / retryable"],
45
+ "standards": [
46
+ "docs/code_quality.md",
47
+ "docs/data_engineering_standards.md",
48
+ "docs/WIRE.md",
49
+ ],
50
+ }
walla/core/path.py ADDED
@@ -0,0 +1,32 @@
1
+ """Config paths under ``~/.config/walla`` (override ``WALLA_CONFIG_DIR``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ __all__ = (
9
+ "config_dir",
10
+ "profile_path",
11
+ "session_path",
12
+ "watches_path",
13
+ )
14
+
15
+
16
+ def config_dir() -> Path:
17
+ override = os.environ.get("WALLA_CONFIG_DIR")
18
+ if override:
19
+ return Path(override).expanduser()
20
+ return Path.home() / ".config" / "walla"
21
+
22
+
23
+ def profile_path() -> Path:
24
+ return config_dir() / "profile.json"
25
+
26
+
27
+ def session_path() -> Path:
28
+ return config_dir() / "session.json"
29
+
30
+
31
+ def watches_path() -> Path:
32
+ return config_dir() / "watches.json"
walla/http/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """HTTP package facade."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from walla.http.client import HttpClient
6
+ from walla.http.headers import API_BASE, WEB_BASE
7
+ from walla.http.polite import reset_polite
8
+
9
+ __all__ = (
10
+ "API_BASE",
11
+ "WEB_BASE",
12
+ "HttpClient",
13
+ "reset_polite",
14
+ )
walla/http/client.py ADDED
@@ -0,0 +1,169 @@
1
+ """curl_cffi HTTP client with politeness and retries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any, Literal, cast
7
+
8
+ from curl_cffi import requests
9
+ from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
10
+
11
+ from walla.core.exceptions import (
12
+ WallaForbiddenError,
13
+ WallaHTTPError,
14
+ WallaRateLimitError,
15
+ )
16
+ from walla.http.headers import API_BASE, default_headers
17
+ from walla.http.polite import (
18
+ cache_get,
19
+ cache_key,
20
+ cache_set,
21
+ check_circuit,
22
+ trip_circuit,
23
+ wait_turn,
24
+ )
25
+
26
+ __all__ = (
27
+ "HttpClient",
28
+ "parse_retry_after",
29
+ )
30
+
31
+ Method = Literal["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "TRACE", "PATCH"]
32
+
33
+
34
+ def parse_retry_after(value: str | None) -> float | None:
35
+ if not value:
36
+ return None
37
+ try:
38
+ return float(value)
39
+ except ValueError:
40
+ return None
41
+
42
+
43
+ def _retryable_http(exc: BaseException) -> bool:
44
+ if isinstance(exc, WallaForbiddenError):
45
+ return False
46
+ if isinstance(exc, WallaRateLimitError):
47
+ return True
48
+ if isinstance(exc, WallaHTTPError):
49
+ code = exc.status_code or 0
50
+ return code >= 500
51
+ return False
52
+
53
+
54
+ class HttpClient:
55
+ """Thin Wallapop HTTP shell. No import-time network."""
56
+
57
+ def __init__(
58
+ self,
59
+ *,
60
+ access_token: str | None = None,
61
+ device_id: str | None = None,
62
+ base: str = API_BASE,
63
+ cookies: dict[str, str] | None = None,
64
+ ) -> None:
65
+ self.base = base.rstrip("/")
66
+ self.access_token = access_token
67
+ self.device_id = device_id
68
+ self.cookies = cookies or {}
69
+ self._session: Any = requests.Session()
70
+
71
+ def request(
72
+ self,
73
+ method: str,
74
+ path: str,
75
+ *,
76
+ params: dict[str, Any] | None = None,
77
+ json_body: dict[str, Any] | None = None,
78
+ use_cache: bool = False,
79
+ auth: bool = False,
80
+ ) -> Any:
81
+ check_circuit()
82
+ params = params or {}
83
+ key = cache_key(f"{method}:{path}", params)
84
+ if use_cache and method.upper() == "GET":
85
+ hit = cache_get(key)
86
+ if hit is not None:
87
+ return hit
88
+ wait_turn()
89
+ headers = default_headers(device_id=self.device_id)
90
+ if auth and self.access_token:
91
+ headers["Authorization"] = f"Bearer {self.access_token}"
92
+ url = path if path.startswith("http") else f"{self.base}{path}"
93
+ resp = self._send(method, url, headers=headers, params=params, json_body=json_body)
94
+ data = self._decode(resp)
95
+ if use_cache and method.upper() == "GET":
96
+ cache_set(key, data)
97
+ return data
98
+
99
+ def get(self, path: str, **kwargs: Any) -> Any:
100
+ return self.request("GET", path, **kwargs)
101
+
102
+ def post(self, path: str, **kwargs: Any) -> Any:
103
+ return self.request("POST", path, **kwargs)
104
+
105
+ def delete(self, path: str, **kwargs: Any) -> Any:
106
+ return self.request("DELETE", path, **kwargs)
107
+
108
+ @retry(
109
+ retry=retry_if_exception(_retryable_http),
110
+ stop=stop_after_attempt(3),
111
+ wait=wait_exponential(multiplier=0.5, min=0.5, max=8),
112
+ reraise=True,
113
+ )
114
+ def _send(
115
+ self,
116
+ method: str,
117
+ url: str,
118
+ *,
119
+ headers: dict[str, str],
120
+ params: dict[str, Any],
121
+ json_body: dict[str, Any] | None,
122
+ ) -> Any:
123
+ resp = self._session.request(
124
+ cast(Method, method.upper()),
125
+ url,
126
+ headers=headers,
127
+ params=params or None,
128
+ json=json_body,
129
+ cookies=self.cookies or None,
130
+ impersonate="chrome",
131
+ timeout=30,
132
+ )
133
+ if resp.status_code == 429:
134
+ ra = parse_retry_after(resp.headers.get("Retry-After"))
135
+ if ra:
136
+ time.sleep(ra)
137
+ raise WallaRateLimitError(
138
+ "Wallapop rate limited (429)",
139
+ retry_after_s=ra,
140
+ )
141
+ if resp.status_code == 403:
142
+ trip_circuit()
143
+ raise WallaForbiddenError(
144
+ "Wallapop Forbidden (403) -- circuit open",
145
+ status_code=403,
146
+ )
147
+ if resp.status_code >= 500:
148
+ raise WallaHTTPError(
149
+ f"Wallapop server error {resp.status_code}",
150
+ status_code=resp.status_code,
151
+ )
152
+ return resp
153
+
154
+ def _decode(self, resp: Any) -> Any:
155
+ if resp.status_code in (200, 201, 204):
156
+ if not resp.content:
157
+ return {}
158
+ try:
159
+ return cast(Any, resp.json())
160
+ except Exception as exc:
161
+ raise WallaHTTPError(
162
+ f"Invalid JSON ({resp.status_code})",
163
+ status_code=resp.status_code,
164
+ ) from exc
165
+ body = (resp.text or "")[:300]
166
+ raise WallaHTTPError(
167
+ f"HTTP {resp.status_code}: {body}",
168
+ status_code=resp.status_code,
169
+ )
walla/http/headers.py ADDED
@@ -0,0 +1,36 @@
1
+ """Default browser-like headers for api.wallapop.com."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+ __all__ = (
8
+ "API_BASE",
9
+ "WEB_BASE",
10
+ "default_headers",
11
+ "new_device_id",
12
+ )
13
+
14
+ API_BASE = "https://api.wallapop.com"
15
+ WEB_BASE = "https://es.wallapop.com"
16
+
17
+
18
+ def new_device_id() -> str:
19
+ return str(uuid.uuid4())
20
+
21
+
22
+ def default_headers(*, device_id: str | None = None) -> dict[str, str]:
23
+ did = device_id or new_device_id()
24
+ return {
25
+ "Accept": "application/json",
26
+ "User-Agent": (
27
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
28
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
29
+ ),
30
+ "Origin": WEB_BASE,
31
+ "Referer": f"{WEB_BASE}/",
32
+ "X-DeviceOS": "0",
33
+ "DeviceOS": "0",
34
+ "X-DeviceID": did,
35
+ "DeviceID": did,
36
+ }
walla/http/polite.py ADDED
@@ -0,0 +1,108 @@
1
+ """Process-wide Wallapop politeness: spacing, short TTL cache, ban circuit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import threading
9
+ import time
10
+ from typing import Any
11
+
12
+ from walla.core.exceptions import WallaForbiddenError
13
+
14
+ __all__ = (
15
+ "CACHE_MAX",
16
+ "CACHE_TTL",
17
+ "CIRCUIT_TTL",
18
+ "MIN_INTERVAL",
19
+ "cache_get",
20
+ "cache_key",
21
+ "cache_set",
22
+ "check_circuit",
23
+ "reset_polite",
24
+ "trip_circuit",
25
+ "wait_turn",
26
+ )
27
+
28
+ MIN_INTERVAL = float(os.environ.get("WALLA_MIN_INTERVAL", "1.0"))
29
+ CACHE_TTL = float(os.environ.get("WALLA_CACHE_TTL", "60"))
30
+ CIRCUIT_TTL = float(os.environ.get("WALLA_CIRCUIT_TTL", "3600"))
31
+ CACHE_MAX = int(os.environ.get("WALLA_CACHE_MAX", "256"))
32
+
33
+ _lock = threading.Lock()
34
+ _last_request_at = 0.0
35
+ _circuit_open_until = 0.0
36
+ _cache: dict[str, tuple[float, Any]] = {}
37
+
38
+
39
+ def reset_polite() -> None:
40
+ """Clear spacing / cache / circuit (tests)."""
41
+ global _last_request_at, _circuit_open_until, _cache
42
+ with _lock:
43
+ _last_request_at = 0.0
44
+ _circuit_open_until = 0.0
45
+ _cache = {}
46
+
47
+
48
+ def cache_key(base: str, params: dict[str, Any]) -> str:
49
+ payload = json.dumps({"base": base, "params": params}, sort_keys=True, default=str)
50
+ return hashlib.sha256(payload.encode()).hexdigest()
51
+
52
+
53
+ def cache_get(key: str) -> Any | None:
54
+ now = time.monotonic()
55
+ with _lock:
56
+ hit = _cache.get(key)
57
+ if hit is None:
58
+ return None
59
+ expires, value = hit
60
+ if expires <= now:
61
+ del _cache[key]
62
+ return None
63
+ return value
64
+
65
+
66
+ def cache_set(key: str, value: Any, *, ttl: float | None = None) -> None:
67
+ life = CACHE_TTL if ttl is None else ttl
68
+ with _lock:
69
+ if len(_cache) >= CACHE_MAX:
70
+ now = time.monotonic()
71
+ expired = [k for k, (exp, _) in _cache.items() if exp <= now]
72
+ for k in expired:
73
+ del _cache[k]
74
+ while len(_cache) >= CACHE_MAX:
75
+ _cache.pop(next(iter(_cache)))
76
+ _cache[key] = (time.monotonic() + life, value)
77
+
78
+
79
+ def check_circuit() -> None:
80
+ with _lock:
81
+ open_until = _circuit_open_until
82
+ if open_until and time.monotonic() < open_until:
83
+ raise WallaForbiddenError(
84
+ "Wallapop Forbidden circuit open -- stop requests. "
85
+ "Wait for WALLA_CIRCUIT_TTL or reset_polite().",
86
+ status_code=403,
87
+ )
88
+
89
+
90
+ def trip_circuit(*, ttl: float | None = None) -> None:
91
+ life = CIRCUIT_TTL if ttl is None else ttl
92
+ global _circuit_open_until
93
+ with _lock:
94
+ _circuit_open_until = time.monotonic() + max(life, 0.0)
95
+
96
+
97
+ def wait_turn(*, min_interval: float | None = None) -> None:
98
+ """Serialize dials with a minimum gap (sleep outside the lock)."""
99
+ gap = MIN_INTERVAL if min_interval is None else min_interval
100
+ global _last_request_at
101
+ while True:
102
+ with _lock:
103
+ now = time.monotonic()
104
+ delay = (_last_request_at + gap) - now
105
+ if delay <= 0:
106
+ _last_request_at = now
107
+ return
108
+ time.sleep(delay)
@@ -0,0 +1,18 @@
1
+ """Hunter package facade."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from walla.hunter.profile_store import load_profile, parse_intake, save_profile
6
+ from walla.hunter.verdict import score_listing
7
+ from walla.hunter.watches import Watch, diff_new_ids, load_watches, save_watches
8
+
9
+ __all__ = (
10
+ "Watch",
11
+ "diff_new_ids",
12
+ "load_profile",
13
+ "load_watches",
14
+ "parse_intake",
15
+ "save_profile",
16
+ "save_watches",
17
+ "score_listing",
18
+ )
@@ -0,0 +1,56 @@
1
+ """Profile store on disk."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from walla.core.path import profile_path
9
+ from walla.models.profile import Profile
10
+
11
+ __all__ = (
12
+ "load_profile",
13
+ "parse_intake",
14
+ "save_profile",
15
+ )
16
+
17
+
18
+ def load_profile(path: Path | None = None) -> Profile:
19
+ p = path or profile_path()
20
+ if not p.is_file():
21
+ return Profile()
22
+ data = json.loads(p.read_text(encoding="utf-8"))
23
+ return Profile.model_validate(data)
24
+
25
+
26
+ def save_profile(profile: Profile, path: Path | None = None) -> Path:
27
+ p = path or profile_path()
28
+ p.parent.mkdir(parents=True, exist_ok=True)
29
+ p.write_text(profile.model_dump_json(indent=2), encoding="utf-8")
30
+ return p
31
+
32
+
33
+ def parse_intake(text: str) -> Profile:
34
+ """Parse ``key=value`` intake line into a Profile."""
35
+ fields: dict[str, str] = {}
36
+ for part in text.replace(",", " ").split():
37
+ if "=" not in part:
38
+ continue
39
+ k, _, v = part.partition("=")
40
+ fields[k.strip().lower()] = v.strip()
41
+ profile = load_profile()
42
+ if "lat" in fields:
43
+ profile.lat = float(fields["lat"])
44
+ if "lon" in fields:
45
+ profile.lon = float(fields["lon"])
46
+ if "km" in fields:
47
+ profile.km = float(fields["km"])
48
+ if "pickup_km" in fields:
49
+ profile.pickup_km = float(fields["pickup_km"])
50
+ if "label" in fields:
51
+ profile.label = fields["label"]
52
+ if "budget" in fields:
53
+ profile.budget = float(fields["budget"])
54
+ if "max_price" in fields:
55
+ profile.max_price = float(fields["max_price"])
56
+ return profile