odoo-agent-cli 0.2.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.
odoocli/client.py ADDED
@@ -0,0 +1,312 @@
1
+ """Async Odoo JSON-RPC client. No env, no printing; logs through ``logging`` only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import random
9
+ import time
10
+ from types import TracebackType
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ from odoocli._version import __version__
16
+ from odoocli.errors import OdooAuthError, OdooConnectionError, classify_rpc_error
17
+ from odoocli.security import is_read_safe_method
18
+
19
+ Domain = list[Any]
20
+
21
+ logger = logging.getLogger("odoocli.rpc")
22
+
23
+
24
+ class AsyncOdooClient:
25
+ """Talk to one Odoo database over ``/jsonrpc``.
26
+
27
+ ``api_key`` is what goes in the password slot of ``common.authenticate``:
28
+ an API key (Odoo 14+) or the user's password.
29
+
30
+ ``context`` is merged into every ``execute_kw`` call (``lang``,
31
+ ``allowed_company_ids``, ``active_test``, ...); a per-call ``context``
32
+ keyword argument overrides keys of the client-wide one.
33
+
34
+ Retry policy: HTTP 429 is always retried (Odoo rejected the request before
35
+ running it). Network errors, timeouts and HTTP 5xx are retried only for
36
+ calls that cannot change data (``common.*`` and read-safe ORM methods),
37
+ because a ``create`` that timed out may well have been committed.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ url: str,
43
+ database: str,
44
+ login: str,
45
+ api_key: str,
46
+ *,
47
+ timeout: float = 30.0,
48
+ max_retries: int = 3,
49
+ retry_base_delay: float = 1.0,
50
+ retry_max_delay: float = 8.0,
51
+ verify_ssl: bool = True,
52
+ context: dict[str, Any] | None = None,
53
+ ) -> None:
54
+ self.url = url.rstrip("/")
55
+ self.database = database
56
+ self.login = login
57
+ self._api_key = api_key
58
+ self.max_retries = max_retries
59
+ self.retry_base_delay = retry_base_delay
60
+ self.retry_max_delay = retry_max_delay
61
+ self.verify_ssl = verify_ssl
62
+ self.context: dict[str, Any] = dict(context or {})
63
+ # Connect tight so a dead host or firewall surfaces fast; keep the
64
+ # read budget close to the total.
65
+ self._timeout = httpx.Timeout(
66
+ connect=min(5.0, timeout),
67
+ read=max(1.0, timeout - 5.0),
68
+ write=max(1.0, timeout - 5.0),
69
+ pool=5.0,
70
+ )
71
+ self._uid: int | None = None
72
+ self._http: httpx.AsyncClient | None = None
73
+
74
+ def __repr__(self) -> str:
75
+ return (
76
+ f"AsyncOdooClient(url={self.url!r}, database={self.database!r}, "
77
+ f"login={self.login!r}, uid={self._uid!r})"
78
+ )
79
+
80
+ # ----- lifecycle -----
81
+
82
+ @property
83
+ def uid(self) -> int | None:
84
+ return self._uid
85
+
86
+ async def __aenter__(self) -> AsyncOdooClient:
87
+ return self
88
+
89
+ async def __aexit__(
90
+ self,
91
+ exc_type: type[BaseException] | None,
92
+ exc: BaseException | None,
93
+ tb: TracebackType | None,
94
+ ) -> None:
95
+ await self.close()
96
+
97
+ async def close(self) -> None:
98
+ if self._http is not None and not self._http.is_closed:
99
+ await self._http.aclose()
100
+ self._http = None
101
+
102
+ def _client(self) -> httpx.AsyncClient:
103
+ if self._http is None or self._http.is_closed:
104
+ self._http = httpx.AsyncClient(
105
+ timeout=self._timeout,
106
+ headers={"User-Agent": f"odoocli/{__version__}"},
107
+ verify=self.verify_ssl,
108
+ )
109
+ return self._http
110
+
111
+ # ----- transport -----
112
+
113
+ def _retry_delay(self, attempt: int, retry_after: str | None = None) -> float:
114
+ if retry_after:
115
+ try:
116
+ return max(0.0, min(float(retry_after), self.retry_max_delay))
117
+ except ValueError:
118
+ pass # HTTP-date format: fall back to backoff
119
+ base = min(self.retry_base_delay * (2**attempt), self.retry_max_delay)
120
+ jitter: float = 0.5 + random.random() / 2
121
+ return float(base * jitter)
122
+
123
+ async def _rpc(
124
+ self, service: str, method: str, args: list[Any], *, retryable: bool = True
125
+ ) -> Any:
126
+ request_id = random.randint(1, 1_000_000)
127
+ payload = {
128
+ "jsonrpc": "2.0",
129
+ "method": "call",
130
+ "params": {"service": service, "method": method, "args": args},
131
+ "id": request_id,
132
+ }
133
+ url = f"{self.url}/jsonrpc"
134
+ client = self._client()
135
+ label = f"{service}.{method}" if service == "common" else f"{args[3]}.{args[4]}"
136
+ started = time.perf_counter()
137
+ logger.debug("-> %s id=%d", label, request_id)
138
+
139
+ response: httpx.Response | None = None
140
+ for attempt in range(self.max_retries + 1):
141
+ last = attempt == self.max_retries
142
+ try:
143
+ response = await client.post(url, json=payload)
144
+ except httpx.HTTPError as e:
145
+ if not retryable or last:
146
+ raise OdooConnectionError(
147
+ f"Cannot reach {url}: {e}", code="connection_error"
148
+ ) from e
149
+ delay = self._retry_delay(attempt)
150
+ logger.warning(
151
+ "%s failed (%s), retry %d/%d in %.1fs",
152
+ label,
153
+ type(e).__name__,
154
+ attempt + 1,
155
+ self.max_retries,
156
+ delay,
157
+ )
158
+ await asyncio.sleep(delay)
159
+ continue
160
+ status = response.status_code
161
+ if status == 429 and not last:
162
+ delay = self._retry_delay(attempt, response.headers.get("Retry-After"))
163
+ logger.warning(
164
+ "%s rate limited (429), retry %d/%d in %.1fs",
165
+ label,
166
+ attempt + 1,
167
+ self.max_retries,
168
+ delay,
169
+ )
170
+ await asyncio.sleep(delay)
171
+ continue
172
+ if status >= 500 and retryable and not last:
173
+ delay = self._retry_delay(attempt, response.headers.get("Retry-After"))
174
+ logger.warning(
175
+ "%s got HTTP %d, retry %d/%d in %.1fs",
176
+ label,
177
+ status,
178
+ attempt + 1,
179
+ self.max_retries,
180
+ delay,
181
+ )
182
+ await asyncio.sleep(delay)
183
+ continue
184
+ break
185
+
186
+ assert response is not None
187
+ elapsed_ms = (time.perf_counter() - started) * 1000
188
+ if response.status_code == 429:
189
+ raise OdooConnectionError(
190
+ f"Odoo rate limited the request (HTTP 429) after {self.max_retries + 1} attempts",
191
+ code="rate_limited",
192
+ )
193
+ if response.status_code >= 400:
194
+ raise OdooConnectionError(f"HTTP {response.status_code} from {url}", code="http_error")
195
+ try:
196
+ data = response.json()
197
+ except (json.JSONDecodeError, ValueError) as e:
198
+ raise OdooConnectionError(
199
+ f"{url} did not answer JSON-RPC (is this an Odoo server URL?)",
200
+ code="not_jsonrpc",
201
+ ) from e
202
+ if not isinstance(data, dict):
203
+ raise OdooConnectionError(f"{url} did not answer JSON-RPC", code="not_jsonrpc")
204
+ if "error" in data:
205
+ err = classify_rpc_error(data["error"])
206
+ logger.debug("!! %s id=%d %.1fms %s: %s", label, request_id, elapsed_ms, err.code, err)
207
+ raise err
208
+ logger.debug("<- %s id=%d %.1fms", label, request_id, elapsed_ms)
209
+ return data.get("result")
210
+
211
+ # ----- public API -----
212
+
213
+ async def version(self) -> dict[str, Any]:
214
+ """Server version info. Works without credentials."""
215
+ result = await self._rpc("common", "version", [])
216
+ return result if isinstance(result, dict) else {}
217
+
218
+ async def authenticate(self) -> int:
219
+ if self._uid is not None:
220
+ return self._uid
221
+ uid = await self._rpc(
222
+ "common", "authenticate", [self.database, self.login, self._api_key, {}]
223
+ )
224
+ if not isinstance(uid, int) or isinstance(uid, bool) or uid <= 0:
225
+ raise OdooAuthError(
226
+ f"Authentication failed for {self.login!r} on database {self.database!r}"
227
+ )
228
+ self._uid = uid
229
+ return uid
230
+
231
+ async def execute(self, model: str, method: str, *args: Any, **kwargs: Any) -> Any:
232
+ """``execute_kw`` with the client context merged into ``kwargs["context"]``."""
233
+ uid = await self.authenticate()
234
+ call_context = kwargs.pop("context", None) or {}
235
+ merged = {**self.context, **call_context}
236
+ if merged:
237
+ kwargs["context"] = merged
238
+ return await self._rpc(
239
+ "object",
240
+ "execute_kw",
241
+ [self.database, uid, self._api_key, model, method, list(args), kwargs],
242
+ retryable=is_read_safe_method(method),
243
+ )
244
+
245
+ @staticmethod
246
+ def _page_kwargs(
247
+ fields: list[str] | None, limit: int | None, offset: int, order: str | None
248
+ ) -> dict[str, Any]:
249
+ kwargs: dict[str, Any] = {}
250
+ if fields:
251
+ kwargs["fields"] = fields
252
+ if limit is not None:
253
+ kwargs["limit"] = limit
254
+ if offset:
255
+ kwargs["offset"] = offset
256
+ if order:
257
+ kwargs["order"] = order
258
+ return kwargs
259
+
260
+ async def search(
261
+ self,
262
+ model: str,
263
+ domain: Domain | None = None,
264
+ limit: int | None = None,
265
+ offset: int = 0,
266
+ order: str | None = None,
267
+ ) -> list[int]:
268
+ """Record ids matching ``domain``."""
269
+ kwargs = self._page_kwargs(None, limit, offset, order)
270
+ result = await self.execute(model, "search", domain or [], **kwargs)
271
+ return [int(i) for i in result] if isinstance(result, list) else []
272
+
273
+ async def search_read(
274
+ self,
275
+ model: str,
276
+ domain: Domain | None = None,
277
+ fields: list[str] | None = None,
278
+ limit: int | None = None,
279
+ offset: int = 0,
280
+ order: str | None = None,
281
+ ) -> list[dict[str, Any]]:
282
+ kwargs = self._page_kwargs(fields, limit, offset, order)
283
+ result = await self.execute(model, "search_read", domain or [], **kwargs)
284
+ return list(result) if isinstance(result, list) else []
285
+
286
+ async def search_count(self, model: str, domain: Domain | None = None) -> int:
287
+ result = await self.execute(model, "search_count", domain or [])
288
+ return int(result)
289
+
290
+ async def read(
291
+ self, model: str, ids: list[int], fields: list[str] | None = None
292
+ ) -> list[dict[str, Any]]:
293
+ kwargs: dict[str, Any] = {"fields": fields} if fields else {}
294
+ result = await self.execute(model, "read", ids, **kwargs)
295
+ return list(result) if isinstance(result, list) else []
296
+
297
+ async def create(self, model: str, values: dict[str, Any] | list[dict[str, Any]]) -> Any:
298
+ """Create one record (dict) or several (list of dicts). Returns id or ids."""
299
+ return await self.execute(model, "create", values)
300
+
301
+ async def write(self, model: str, ids: list[int], values: dict[str, Any]) -> bool:
302
+ return bool(await self.execute(model, "write", ids, values))
303
+
304
+ async def unlink(self, model: str, ids: list[int]) -> bool:
305
+ return bool(await self.execute(model, "unlink", ids))
306
+
307
+ async def fields_get(
308
+ self, model: str, attributes: list[str] | None = None
309
+ ) -> dict[str, dict[str, Any]]:
310
+ kwargs: dict[str, Any] = {"attributes": attributes} if attributes else {}
311
+ result = await self.execute(model, "fields_get", **kwargs)
312
+ return dict(result) if isinstance(result, dict) else {}
odoocli/config.py ADDED
@@ -0,0 +1,175 @@
1
+ """Profiles file and connection resolution. The only library module allowed to read env."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tomllib
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import tomli_w
13
+
14
+ from odoocli.errors import OdooConnectionError
15
+
16
+ ENV_URL, ENV_DB, ENV_LOGIN, ENV_KEY = "ODOO_URL", "ODOO_DB", "ODOO_LOGIN", "ODOO_API_KEY"
17
+ ENV_REQUIRED = (ENV_URL, ENV_DB, ENV_LOGIN, ENV_KEY)
18
+ ENV_PROFILE = "ODOO_PROFILE"
19
+ ENV_CONFIG = "ODOO_CONFIG"
20
+ ENV_ALLOW_WRITES = "ODOO_ALLOW_WRITES"
21
+ ENV_ALLOW_SENSITIVE = "ODOO_ALLOW_SENSITIVE"
22
+ ENV_ASSUME_YES = "ODOO_ASSUME_YES"
23
+ ENV_VERIFY_SSL = "ODOO_VERIFY_SSL"
24
+
25
+ _TRUE = {"1", "true", "yes", "on"}
26
+ _FALSE = {"0", "false", "no", "off"}
27
+
28
+ NO_CONNECTION_HELP = (
29
+ "No Odoo connection configured. Use one of:\n"
30
+ " 1. odoo --profile NAME ... (or ODOO_PROFILE=NAME) after 'odoo profile add NAME ...'\n"
31
+ " 2. environment: ODOO_URL, ODOO_DB, ODOO_LOGIN, ODOO_API_KEY\n"
32
+ " 3. a profile named 'default' in the config file"
33
+ )
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class Profile:
38
+ name: str
39
+ url: str
40
+ database: str
41
+ login: str
42
+ api_key: str = field(repr=False)
43
+ allow_writes: bool = False
44
+ allow_sensitive: bool = False
45
+ verify_ssl: bool = True
46
+ source: str = "env"
47
+
48
+
49
+ def env_flag(env: Mapping[str, str], name: str) -> bool:
50
+ return env.get(name, "").strip().lower() in _TRUE
51
+
52
+
53
+ def env_flag_default_true(env: Mapping[str, str], name: str) -> bool:
54
+ return env.get(name, "").strip().lower() not in _FALSE
55
+
56
+
57
+ def config_path(env: Mapping[str, str]) -> Path:
58
+ if env.get(ENV_CONFIG):
59
+ return Path(env[ENV_CONFIG]).expanduser()
60
+ base = env.get("XDG_CONFIG_HOME")
61
+ root = Path(base).expanduser() if base else Path.home() / ".config"
62
+ return root / "odoo-cli" / "config.toml"
63
+
64
+
65
+ def _read(path: Path) -> dict[str, Any]:
66
+ if not path.exists():
67
+ return {}
68
+ with path.open("rb") as fh:
69
+ return tomllib.load(fh)
70
+
71
+
72
+ def _write(path: Path, data: dict[str, Any]) -> None:
73
+ path.parent.mkdir(parents=True, exist_ok=True)
74
+ os.chmod(path.parent, 0o700)
75
+ tmp = path.with_suffix(".tmp")
76
+ with tmp.open("wb") as fh:
77
+ tomli_w.dump(data, fh)
78
+ os.chmod(tmp, 0o600)
79
+ os.replace(tmp, path)
80
+
81
+
82
+ def load_profiles(path: Path) -> dict[str, dict[str, Any]]:
83
+ profiles = _read(path).get("profiles", {})
84
+ return {str(k): dict(v) for k, v in profiles.items() if isinstance(v, dict)}
85
+
86
+
87
+ def save_profile(path: Path, name: str, data: dict[str, Any]) -> None:
88
+ doc = _read(path)
89
+ profiles = doc.setdefault("profiles", {})
90
+ profiles[name] = {k: v for k, v in data.items() if v is not None}
91
+ _write(path, doc)
92
+
93
+
94
+ def remove_profile(path: Path, name: str) -> bool:
95
+ doc = _read(path)
96
+ profiles = doc.get("profiles", {})
97
+ if name not in profiles:
98
+ return False
99
+ del profiles[name]
100
+ _write(path, doc)
101
+ return True
102
+
103
+
104
+ def profile_from_dict(name: str, data: Mapping[str, Any], env: Mapping[str, str]) -> Profile:
105
+ missing = [k for k in ("url", "database", "login") if not data.get(k)]
106
+ if missing:
107
+ raise OdooConnectionError(
108
+ f"Profile {name!r} is missing {', '.join(missing)}", code="invalid_profile"
109
+ )
110
+ api_key = data.get("api_key")
111
+ key_env = data.get("api_key_env")
112
+ if not api_key and key_env:
113
+ api_key = env.get(str(key_env))
114
+ if not api_key:
115
+ raise OdooConnectionError(
116
+ f"Profile {name!r} reads its key from ${key_env}, which is not set",
117
+ code="invalid_profile",
118
+ )
119
+ if not api_key:
120
+ raise OdooConnectionError(
121
+ f"Profile {name!r} has neither api_key nor api_key_env", code="invalid_profile"
122
+ )
123
+ return Profile(
124
+ name=name,
125
+ url=str(data["url"]),
126
+ database=str(data["database"]),
127
+ login=str(data["login"]),
128
+ api_key=str(api_key),
129
+ allow_writes=bool(data.get("allow_writes", False)),
130
+ allow_sensitive=bool(data.get("allow_sensitive", False)),
131
+ verify_ssl=bool(data.get("verify_ssl", True)),
132
+ source=f"profile:{name}",
133
+ )
134
+
135
+
136
+ def _from_env(env: Mapping[str, str]) -> Profile:
137
+ return Profile(
138
+ name="env",
139
+ url=env[ENV_URL],
140
+ database=env[ENV_DB],
141
+ login=env[ENV_LOGIN],
142
+ api_key=env[ENV_KEY],
143
+ allow_writes=env_flag(env, ENV_ALLOW_WRITES),
144
+ allow_sensitive=env_flag(env, ENV_ALLOW_SENSITIVE),
145
+ verify_ssl=env_flag_default_true(env, ENV_VERIFY_SSL),
146
+ source="env",
147
+ )
148
+
149
+
150
+ def resolve_profile(explicit: str | None, env: Mapping[str, str], path: Path) -> Profile:
151
+ """--profile > ODOO_PROFILE > ODOO_* env > profile 'default'. Never prompts."""
152
+ name = explicit or env.get(ENV_PROFILE) or None
153
+ if name:
154
+ profiles = load_profiles(path)
155
+ if name not in profiles:
156
+ raise OdooConnectionError(
157
+ f"Profile {name!r} not found in {path}. Run 'odoo profile add {name} ...'",
158
+ code="no_connection",
159
+ )
160
+ return profile_from_dict(name, profiles[name], env)
161
+
162
+ present = [k for k in ENV_REQUIRED if env.get(k)]
163
+ if present:
164
+ missing = [k for k in ENV_REQUIRED if not env.get(k)]
165
+ if missing:
166
+ raise OdooConnectionError(
167
+ f"Incomplete ODOO_* environment, missing: {', '.join(missing)}",
168
+ code="no_connection",
169
+ )
170
+ return _from_env(env)
171
+
172
+ profiles = load_profiles(path)
173
+ if "default" in profiles:
174
+ return profile_from_dict("default", profiles["default"], env)
175
+ raise OdooConnectionError(NO_CONNECTION_HELP, code="no_connection")
odoocli/domain.py ADDED
@@ -0,0 +1,208 @@
1
+ """Odoo domain helpers: normalisation, de-humanisation, safe field removal, -w DSL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import json
7
+ import re
8
+ from typing import Any
9
+
10
+ from odoocli.errors import OdooUsageError
11
+
12
+ _BINARY_OPS = ("&", "|")
13
+ _UNARY_OP = "!"
14
+ # Trailing "(#42)" of a humanised many2one ("Name (#42)"), as produced by
15
+ # LLM-facing layers. Recovered back to the int id when fed into a domain.
16
+ _HUMANIZED_M2O_RE = re.compile(r"\(#(\d+)\)\s*$")
17
+ _DROP = object()
18
+
19
+
20
+ # ----- normalisation (ported from UpBoard's OdooConnector) -----
21
+
22
+
23
+ def normalize_domain(domain: Any) -> list[Any]:
24
+ """Accept a list, a JSON string or a Python literal string; keep ``& | !``."""
25
+ if isinstance(domain, list):
26
+ out: list[Any] = []
27
+ for item in domain:
28
+ if isinstance(item, str) and item in ("|", "&", "!"):
29
+ out.append(item)
30
+ elif isinstance(item, str):
31
+ out.extend(normalize_domain(item))
32
+ else:
33
+ out.append(item)
34
+ return out
35
+ if isinstance(domain, str):
36
+ text = domain.strip()
37
+ if not text or text == "[]":
38
+ return []
39
+ parsed: Any = None
40
+ for parser in (json.loads, ast.literal_eval):
41
+ try:
42
+ parsed = parser(text)
43
+ except (ValueError, SyntaxError):
44
+ continue
45
+ if isinstance(parsed, list):
46
+ return parsed
47
+ raise OdooUsageError(f"Domain is neither JSON nor a Python list: {text[:80]!r}")
48
+ return []
49
+
50
+
51
+ def _dehumanize(value: Any) -> Any:
52
+ if isinstance(value, str):
53
+ m = _HUMANIZED_M2O_RE.search(value)
54
+ return int(m.group(1)) if m else value
55
+ if isinstance(value, list | tuple):
56
+ return [_dehumanize(v) for v in value]
57
+ return value
58
+
59
+
60
+ def sanitize_domain(domain: list[Any]) -> list[Any]:
61
+ """Turn ``"Name (#42)"`` operands on ``id``/``*_id``/``*_ids`` fields back into ints."""
62
+ out: list[Any] = []
63
+ for item in domain:
64
+ if isinstance(item, list | tuple) and len(item) == 3:
65
+ field, op, value = item
66
+ if isinstance(field, str) and (field == "id" or field.endswith(("_id", "_ids"))):
67
+ value = _dehumanize(value)
68
+ out.append([field, op, value])
69
+ else:
70
+ out.append(item)
71
+ return out
72
+
73
+
74
+ # ----- safe field removal (used by lenient mode) -----
75
+
76
+
77
+ def _is_leaf(term: Any) -> bool:
78
+ return isinstance(term, list | tuple) and len(term) == 3 and isinstance(term[0], str)
79
+
80
+
81
+ def _parse(tokens: list[Any], pos: int) -> tuple[Any, int]:
82
+ tok = tokens[pos]
83
+ if tok in _BINARY_OPS:
84
+ left, pos = _parse(tokens, pos + 1)
85
+ right, pos = _parse(tokens, pos)
86
+ return ("op2", tok, left, right), pos
87
+ if tok == _UNARY_OP:
88
+ child, pos = _parse(tokens, pos + 1)
89
+ return ("not", child), pos
90
+ return ("leaf", tok), pos + 1
91
+
92
+
93
+ def _serialize(node: Any, bad_field: str) -> Any:
94
+ kind = node[0]
95
+ if kind == "leaf":
96
+ term = node[1]
97
+ if _is_leaf(term) and term[0] == bad_field:
98
+ return _DROP
99
+ return [term]
100
+ if kind == "not":
101
+ child = _serialize(node[1], bad_field)
102
+ return _DROP if child is _DROP else [_UNARY_OP, *child]
103
+ _, operator, left_node, right_node = node
104
+ left = _serialize(left_node, bad_field)
105
+ right = _serialize(right_node, bad_field)
106
+ if left is _DROP and right is _DROP:
107
+ return _DROP
108
+ if left is _DROP:
109
+ return right
110
+ if right is _DROP:
111
+ return left
112
+ return [operator, *left, *right]
113
+
114
+
115
+ def strip_field_from_domain(domain: Any, bad_field: str) -> Any:
116
+ """Remove every leaf on ``bad_field``; ``|``/``&`` fold onto the surviving operand."""
117
+ if not isinstance(domain, list) or not domain:
118
+ return domain
119
+ try:
120
+ cleaned: list[Any] = []
121
+ pos = 0
122
+ while pos < len(domain):
123
+ node, pos = _parse(domain, pos)
124
+ serialized = _serialize(node, bad_field)
125
+ if serialized is not _DROP:
126
+ cleaned.extend(serialized)
127
+ return cleaned
128
+ except (IndexError, TypeError):
129
+ # Malformed domain: leave it alone rather than risk a wrong filter.
130
+ return domain
131
+
132
+
133
+ # ----- -w DSL -----
134
+
135
+ _WORD_OPS = (
136
+ "not in",
137
+ "in",
138
+ "not ilike",
139
+ "ilike",
140
+ "not like",
141
+ "like",
142
+ "=like",
143
+ "=ilike",
144
+ "child_of",
145
+ "parent_of",
146
+ "not any",
147
+ "any",
148
+ )
149
+ _WORD_RE = re.compile(
150
+ r"^\s*([A-Za-z_][\w.]*)\s+(" + "|".join(re.escape(o) for o in _WORD_OPS) + r")\s+(.*?)\s*$"
151
+ )
152
+ _SYMBOL_RE = re.compile(r"^\s*([A-Za-z_][\w.]*)\s*(>=|<=|!=|!~|=|>|<|~)\s*(.*?)\s*$")
153
+ _SYMBOL_MAP = {"~": "ilike", "!~": "not ilike"}
154
+ _LIST_OPS = {"in", "not in"}
155
+
156
+
157
+ def parse_value(raw: str) -> Any:
158
+ """Scalar parsing for -w and -v: quotes, booleans, null, JSON, numbers, else text."""
159
+ text = raw.strip()
160
+ if len(text) >= 2 and text[0] == text[-1] and text[0] in "'\"":
161
+ return text[1:-1]
162
+ lowered = text.lower()
163
+ if lowered in ("true", "yes"):
164
+ return True
165
+ if lowered in ("false", "no", "null", "none"):
166
+ return False
167
+ if text[:1] in "[{":
168
+ try:
169
+ return json.loads(text)
170
+ except ValueError as e:
171
+ raise OdooUsageError(f"Invalid JSON value: {text[:80]!r}") from e
172
+ try:
173
+ return int(text)
174
+ except ValueError:
175
+ pass
176
+ try:
177
+ return float(text)
178
+ except ValueError:
179
+ pass
180
+ return text
181
+
182
+
183
+ def parse_where(expr: str) -> list[Any]:
184
+ """``field op value`` to one Odoo leaf. ``~`` is ilike, ``!~`` is not ilike."""
185
+ m = _WORD_RE.match(expr) or _SYMBOL_RE.match(expr)
186
+ if not m:
187
+ raise OdooUsageError(
188
+ f"Cannot parse condition {expr!r}. Expected 'field=value', 'field>=10', "
189
+ "'field~text', 'field in a,b' or 'field not in a,b'."
190
+ )
191
+ field, op, raw = m.group(1), m.group(2), m.group(3)
192
+ op = _SYMBOL_MAP.get(op, op)
193
+ value: Any
194
+ if op in _LIST_OPS:
195
+ value = (
196
+ parse_value(raw) if raw.startswith("[") else [parse_value(v) for v in raw.split(",")]
197
+ )
198
+ if not isinstance(value, list):
199
+ raise OdooUsageError(f"'{op}' needs a list, got {raw!r}")
200
+ else:
201
+ value = parse_value(raw)
202
+ return [field, op, value]
203
+
204
+
205
+ def build_domain(domain: str | None, where: list[str]) -> list[Any]:
206
+ """AND a JSON domain (optional) with every ``-w`` condition."""
207
+ base = sanitize_domain(normalize_domain(domain)) if domain else []
208
+ return base + [parse_where(w) for w in where]