odoo-agent-cli 0.5.0__tar.gz → 0.6.0__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 (31) hide show
  1. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/PKG-INFO +18 -8
  2. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/README.md +17 -7
  3. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/SKILL.md +4 -2
  4. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/pyproject.toml +1 -1
  5. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/AGENT_GUIDE.md +4 -2
  6. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/__init__.py +2 -0
  7. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/_version.py +1 -1
  8. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/read_cmds.py +59 -10
  9. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/domain.py +32 -17
  10. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/errors.py +30 -0
  11. odoo_agent_cli-0.6.0/src/odoocli/lenient.py +161 -0
  12. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/security.py +1 -0
  13. odoo_agent_cli-0.5.0/src/odoocli/lenient.py +0 -93
  14. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/.gitignore +0 -0
  15. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/LICENSE +0 -0
  16. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/__main__.py +0 -0
  17. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/aliases.py +0 -0
  18. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/__init__.py +0 -0
  19. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/alias_cmd.py +0 -0
  20. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/app.py +0 -0
  21. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/cache_cmds.py +0 -0
  22. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/guide_cmd.py +0 -0
  23. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/output.py +0 -0
  24. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/profile_cmds.py +0 -0
  25. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/values.py +0 -0
  26. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/write_cmds.py +0 -0
  27. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/client.py +0 -0
  28. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/config.py +0 -0
  29. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/py.typed +0 -0
  30. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/schema.py +0 -0
  31. {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/sync.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: odoo-agent-cli
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Odoo JSON-RPC CLI and Python client built for AI agents and scripts.
5
5
  Project-URL: Homepage, https://github.com/Organize-IT/odoo-cli
6
6
  Project-URL: Issues, https://github.com/Organize-IT/odoo-cli/issues
@@ -43,8 +43,10 @@ uv tool install odoo-agent-cli # or: pipx install odoo-agent-cli
43
43
  odoo --version
44
44
  ```
45
45
 
46
- Requires Python 3.11+. Works with Odoo 17, 18 and 19 (integration-tested in CI), and should
47
- work with any version exposing `/jsonrpc` with API keys (14+).
46
+ Requires Python 3.11+. Works with Odoo 15 through 20 (integration-tested in CI on 15.0,
47
+ 16.0, 17.0, 18.0, 19.0 and 20.0, Community), and should work with any version exposing
48
+ `/jsonrpc` with API keys (14+). Odoo 20 still serves `/jsonrpc` but logs it as deprecated;
49
+ Odoo plans to remove it in 22.
48
50
 
49
51
  ## 60 seconds
50
52
 
@@ -196,7 +198,10 @@ odoo group invoices -w overdue --by partner_id --sum amount_residual \
196
198
  odoo group invoices --by invoice_date:month --sum amount_total
197
199
  ```
198
200
 
199
- `read_group` under the hood: counts and totals per group without pulling the records.
201
+ `read_group` under the hood: counts and totals per group without pulling the records. On
202
+ Odoo 20, whose `read_group` no longer answers JSON-RPC callers, it is `formatted_read_group`,
203
+ and its answer is passed through as is: totals are keyed `amount_residual:sum` rather than
204
+ `amount_residual`, and each group's filter is `__extra_domain`.
200
205
 
201
206
  ## Writes
202
207
 
@@ -254,14 +259,17 @@ async with AsyncOdooClient(url, db, login, key) as odoo:
254
259
  ```
255
260
 
256
261
  Exceptions: `OdooError` (base, `.code`, `.message`, `.data`), `OdooConnectionError`,
257
- `OdooAuthError`, `OdooAccessError`, `OdooValidationError`, `OdooMissingError`.
262
+ `OdooAuthError`, `OdooAccessError`, `OdooValidationError`, `OdooMissingError`,
263
+ `OdooFieldMissingError`.
258
264
 
259
265
  Both clients accept `context={...}` (merged into every call; a per-call `context=` keyword
260
266
  wins), `verify_ssl=False` and `max_retries`. HTTP 429 is always retried with backoff and
261
267
  `Retry-After`; network errors, timeouts and HTTP 5xx are retried only for calls that cannot
262
268
  change data, so a `create` that timed out is never replayed. Logs go to the `odoocli.rpc`
263
- logger. Domain helpers live in `odoocli.domain`, guards in `odoocli.security`, the opt-in
264
- repair loop in `odoocli.lenient`.
269
+ logger. Domain helpers live in `odoocli.domain`, guards in `odoocli.security`, the repair
270
+ loop in `odoocli.lenient`: `lenient_search_read` drops rejected fields from `fields` and
271
+ `order` on its own, but a rejected field in the domain raises `OdooFieldMissingError` unless
272
+ you pass `strip_domain=True`, because removing a filter widens the query.
265
273
 
266
274
  ## Development
267
275
 
@@ -274,13 +282,15 @@ uv run pytest --cov --cov-report=term-missing # gated at 93% in CI
274
282
  uv run --group docs mkdocs serve # the documentation site
275
283
 
276
284
  ODOO_VERSION=17.0 scripts/start-odoo.sh # throwaway Odoo in Docker
285
+ # 15.0 to 20.0; 20.0 is built from docker/odoo20/SHA;
286
+ # ODOO_PORT=8169 if 8069 is taken
277
287
  ODOO_URL=http://localhost:8069 ODOO_DB=test ODOO_LOGIN=admin ODOO_API_KEY=admin \
278
288
  ODOO_ALLOW_WRITES=1 uv run pytest -m integration -o addopts=""
279
289
  docker compose -f docker/odoo-compose.yml down -v
280
290
  ```
281
291
 
282
292
  CI runs the unit suite on Python 3.11-3.13 and builds the docs on every PR; the integration
283
- matrix (Odoo 17.0, 18.0, 19.0) runs on `main`, tags and manual dispatch. Releases are
293
+ matrix (Odoo 15.0 to 20.0) runs on `main`, tags and manual dispatch. Releases are
284
294
  published to PyPI on `v*` tags through trusted publishing, with every action pinned to a
285
295
  commit digest.
286
296
 
@@ -16,8 +16,10 @@ uv tool install odoo-agent-cli # or: pipx install odoo-agent-cli
16
16
  odoo --version
17
17
  ```
18
18
 
19
- Requires Python 3.11+. Works with Odoo 17, 18 and 19 (integration-tested in CI), and should
20
- work with any version exposing `/jsonrpc` with API keys (14+).
19
+ Requires Python 3.11+. Works with Odoo 15 through 20 (integration-tested in CI on 15.0,
20
+ 16.0, 17.0, 18.0, 19.0 and 20.0, Community), and should work with any version exposing
21
+ `/jsonrpc` with API keys (14+). Odoo 20 still serves `/jsonrpc` but logs it as deprecated;
22
+ Odoo plans to remove it in 22.
21
23
 
22
24
  ## 60 seconds
23
25
 
@@ -169,7 +171,10 @@ odoo group invoices -w overdue --by partner_id --sum amount_residual \
169
171
  odoo group invoices --by invoice_date:month --sum amount_total
170
172
  ```
171
173
 
172
- `read_group` under the hood: counts and totals per group without pulling the records.
174
+ `read_group` under the hood: counts and totals per group without pulling the records. On
175
+ Odoo 20, whose `read_group` no longer answers JSON-RPC callers, it is `formatted_read_group`,
176
+ and its answer is passed through as is: totals are keyed `amount_residual:sum` rather than
177
+ `amount_residual`, and each group's filter is `__extra_domain`.
173
178
 
174
179
  ## Writes
175
180
 
@@ -227,14 +232,17 @@ async with AsyncOdooClient(url, db, login, key) as odoo:
227
232
  ```
228
233
 
229
234
  Exceptions: `OdooError` (base, `.code`, `.message`, `.data`), `OdooConnectionError`,
230
- `OdooAuthError`, `OdooAccessError`, `OdooValidationError`, `OdooMissingError`.
235
+ `OdooAuthError`, `OdooAccessError`, `OdooValidationError`, `OdooMissingError`,
236
+ `OdooFieldMissingError`.
231
237
 
232
238
  Both clients accept `context={...}` (merged into every call; a per-call `context=` keyword
233
239
  wins), `verify_ssl=False` and `max_retries`. HTTP 429 is always retried with backoff and
234
240
  `Retry-After`; network errors, timeouts and HTTP 5xx are retried only for calls that cannot
235
241
  change data, so a `create` that timed out is never replayed. Logs go to the `odoocli.rpc`
236
- logger. Domain helpers live in `odoocli.domain`, guards in `odoocli.security`, the opt-in
237
- repair loop in `odoocli.lenient`.
242
+ logger. Domain helpers live in `odoocli.domain`, guards in `odoocli.security`, the repair
243
+ loop in `odoocli.lenient`: `lenient_search_read` drops rejected fields from `fields` and
244
+ `order` on its own, but a rejected field in the domain raises `OdooFieldMissingError` unless
245
+ you pass `strip_domain=True`, because removing a filter widens the query.
238
246
 
239
247
  ## Development
240
248
 
@@ -247,13 +255,15 @@ uv run pytest --cov --cov-report=term-missing # gated at 93% in CI
247
255
  uv run --group docs mkdocs serve # the documentation site
248
256
 
249
257
  ODOO_VERSION=17.0 scripts/start-odoo.sh # throwaway Odoo in Docker
258
+ # 15.0 to 20.0; 20.0 is built from docker/odoo20/SHA;
259
+ # ODOO_PORT=8169 if 8069 is taken
250
260
  ODOO_URL=http://localhost:8069 ODOO_DB=test ODOO_LOGIN=admin ODOO_API_KEY=admin \
251
261
  ODOO_ALLOW_WRITES=1 uv run pytest -m integration -o addopts=""
252
262
  docker compose -f docker/odoo-compose.yml down -v
253
263
  ```
254
264
 
255
265
  CI runs the unit suite on Python 3.11-3.13 and builds the docs on every PR; the integration
256
- matrix (Odoo 17.0, 18.0, 19.0) runs on `main`, tags and manual dispatch. Releases are
266
+ matrix (Odoo 15.0 to 20.0) runs on `main`, tags and manual dispatch. Releases are
257
267
  published to PyPI on `v*` tags through trusted publishing, with every action pinned to a
258
268
  commit digest.
259
269
 
@@ -116,7 +116,9 @@ odoo cache path|list|clear the schema cache
116
116
  `MODEL` accepts an alias on every read command. `odoo group` runs `read_group`, so you
117
117
  get counts and totals per group without pulling the records:
118
118
  `odoo group invoices -w overdue --by partner_id --sum amount_residual`.
119
- Grouping keys accept a date granularity: `--by invoice_date:month`.
119
+ Grouping keys accept a date granularity: `--by invoice_date:month`. On Odoo 20 it runs
120
+ `formatted_read_group` and passes its answer through: totals are keyed `amount_residual:sum`
121
+ instead of `amount_residual`, and each group's filter is `__extra_domain`.
120
122
 
121
123
  Conditions (`-w`, repeatable, AND-ed together, combined with `--domain`):
122
124
 
@@ -174,7 +176,7 @@ One2many and many2many fields take Odoo commands, written as JSON in `-v` or `--
174
176
  5. Many2one values come back as `[id, name]`. Filter on them with the id
175
177
  (`-w partner_id=42`) or through a related field (`-w partner_id.name~acme`).
176
178
  6. Dates are strings, `YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS` in UTC.
177
- 7. Field names drift between Odoo 17, 18 and 19 (for example `account.account.company_id`
179
+ 7. Field names drift between Odoo 15 and 20 (for example `account.account.company_id`
178
180
  became `company_ids`). If a field is rejected, `odoo fields` is the truth.
179
181
  `--lenient-fields` on `search` removes rejected fields and retries. It prints the rows and
180
182
  then exits 5, because they answer a wider question than the one you asked. Use it to
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "odoo-agent-cli"
7
- version = "0.5.0"
7
+ version = "0.6.0"
8
8
  description = "Odoo JSON-RPC CLI and Python client built for AI agents and scripts."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -111,7 +111,9 @@ odoo cache path|list|clear the schema cache
111
111
  `MODEL` accepts an alias on every read command. `odoo group` runs `read_group`, so you
112
112
  get counts and totals per group without pulling the records:
113
113
  `odoo group invoices -w overdue --by partner_id --sum amount_residual`.
114
- Grouping keys accept a date granularity: `--by invoice_date:month`.
114
+ Grouping keys accept a date granularity: `--by invoice_date:month`. On Odoo 20 it runs
115
+ `formatted_read_group` and passes its answer through: totals are keyed `amount_residual:sum`
116
+ instead of `amount_residual`, and each group's filter is `__extra_domain`.
115
117
 
116
118
  Conditions (`-w`, repeatable, AND-ed together, combined with `--domain`):
117
119
 
@@ -169,7 +171,7 @@ One2many and many2many fields take Odoo commands, written as JSON in `-v` or `--
169
171
  5. Many2one values come back as `[id, name]`. Filter on them with the id
170
172
  (`-w partner_id=42`) or through a related field (`-w partner_id.name~acme`).
171
173
  6. Dates are strings, `YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS` in UTC.
172
- 7. Field names drift between Odoo 17, 18 and 19 (for example `account.account.company_id`
174
+ 7. Field names drift between Odoo 15 and 20 (for example `account.account.company_id`
173
175
  became `company_ids`). If a field is rejected, `odoo fields` is the truth.
174
176
  `--lenient-fields` on `search` removes rejected fields and retries. It prints the rows and
175
177
  then exits 5, because they answer a wider question than the one you asked. Use it to
@@ -7,6 +7,7 @@ from odoocli.errors import (
7
7
  OdooAuthError,
8
8
  OdooConnectionError,
9
9
  OdooError,
10
+ OdooFieldMissingError,
10
11
  OdooMissingError,
11
12
  OdooRefusedError,
12
13
  OdooRepairedError,
@@ -22,6 +23,7 @@ __all__ = [
22
23
  "OdooClient",
23
24
  "OdooConnectionError",
24
25
  "OdooError",
26
+ "OdooFieldMissingError",
25
27
  "OdooMissingError",
26
28
  "OdooRefusedError",
27
29
  "OdooRepairedError",
@@ -1,3 +1,3 @@
1
1
  """Single source of the package version (kept import-free to avoid cycles)."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.6.0"
@@ -21,7 +21,7 @@ from odoocli.cli.values import parse_ids, split_fields
21
21
  from odoocli.client import AsyncOdooClient
22
22
  from odoocli.config import Profile
23
23
  from odoocli.domain import build_domain
24
- from odoocli.errors import OdooMissingError, OdooRepairedError, OdooUsageError
24
+ from odoocli.errors import OdooError, OdooMissingError, OdooRepairedError, OdooUsageError
25
25
  from odoocli.lenient import lenient_search_read
26
26
 
27
27
  DEFAULT_LIMIT = 80
@@ -175,7 +175,15 @@ def search(
175
175
  ) -> list[dict[str, Any]]:
176
176
  if lenient:
177
177
  return await lenient_search_read(
178
- client, target, dom, flds, lim, off, order, on_warning=note_repair
178
+ client,
179
+ target,
180
+ dom,
181
+ flds,
182
+ lim,
183
+ off,
184
+ order,
185
+ strip_domain=True,
186
+ on_warning=note_repair,
179
187
  )
180
188
  return await client.search_read(target, dom, flds, lim, off, order)
181
189
 
@@ -281,21 +289,62 @@ def group(
281
289
  domain=dom,
282
290
  order=order,
283
291
  )
284
- kwargs: dict[str, Any] = {"lazy": False}
292
+ page: dict[str, Any] = {}
285
293
  if limit is not None:
286
- kwargs["limit"] = limit
294
+ page["limit"] = limit
287
295
  if offset:
288
- kwargs["offset"] = offset
289
- if order:
290
- kwargs["orderby"] = order
291
- result = await client.execute(
292
- target, "read_group", dom, groupby + aggregates, groupby, **kwargs
293
- )
296
+ page["offset"] = offset
297
+ try:
298
+ result = await client.execute(
299
+ target,
300
+ "read_group",
301
+ dom,
302
+ groupby + aggregates,
303
+ groupby,
304
+ lazy=False,
305
+ **page,
306
+ **({"orderby": order} if order else {}),
307
+ )
308
+ except OdooError as e:
309
+ if not _read_group_is_gone(e):
310
+ raise
311
+ # Odoo 20: the public read_group is the ORM's tuple API, the JSON one is
312
+ # formatted_read_group. Its answer is passed through as is: aggregate keys
313
+ # read "amount_total:sum" and the group filter is "__extra_domain".
314
+ if order:
315
+ page["order"] = _aggregate_order(order, groupby, aggregates)
316
+ result = await client.execute(
317
+ target, "formatted_read_group", dom, groupby, ["__count", *aggregates], **page
318
+ )
294
319
  return list(result) if isinstance(result, list) else []
295
320
 
296
321
  emit(ctx, run(ctx, go))
297
322
 
298
323
 
324
+ def _read_group_is_gone(error: OdooError) -> bool:
325
+ """Whether the server's read_group no longer takes the web client's arguments (Odoo 20)."""
326
+ name = (error.data or {}).get("name")
327
+ return name == "builtins.TypeError" and "'lazy'" in error.message
328
+
329
+
330
+ def _aggregate_order(order: str, groupby: list[str], aggregates: list[str]) -> str:
331
+ """Rewrite ``amount_total desc`` as ``amount_total:sum desc`` for formatted_read_group.
332
+
333
+ It only orders by a groupby or an aggregate spec; read_group took the bare field name.
334
+ The first aggregate given for a field wins.
335
+ """
336
+ specs: dict[str, str] = {}
337
+ for spec in aggregates:
338
+ specs.setdefault(spec.split(":", 1)[0], spec)
339
+ terms = []
340
+ for term in (t.strip() for t in order.split(",") if t.strip()):
341
+ name, _, direction = term.partition(" ")
342
+ if name not in groupby and name in specs:
343
+ term = f"{specs[name]} {direction}".strip()
344
+ terms.append(term)
345
+ return ", ".join(terms)
346
+
347
+
299
348
  @app.command()
300
349
  def read(
301
350
  ctx: typer.Context,
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import ast
6
6
  import json
7
7
  import re
8
+ from collections.abc import Callable
8
9
  from datetime import date
9
10
  from typing import Any
10
11
 
@@ -104,18 +105,22 @@ def _parse(tokens: list[Any], pos: int) -> tuple[Any, int]:
104
105
  return ("leaf", tok), pos + 1
105
106
 
106
107
 
107
- def _mentions(node: Any, bad_field: str) -> bool:
108
+ PathMatcher = Callable[[str], bool]
109
+ """Decides whether a leaf's field path (``"partner_id.name"``) is one to remove."""
110
+
111
+
112
+ def _mentions(node: Any, matches: PathMatcher) -> bool:
108
113
  kind = node[0]
109
114
  if kind == "leaf":
110
115
  term = node[1]
111
- return bool(_is_leaf(term) and term[0] == bad_field)
116
+ return bool(_is_leaf(term) and matches(term[0]))
112
117
  if kind == "not":
113
- return _mentions(node[1], bad_field)
114
- return _mentions(node[2], bad_field) or _mentions(node[3], bad_field)
118
+ return _mentions(node[1], matches)
119
+ return _mentions(node[2], matches) or _mentions(node[3], matches)
115
120
 
116
121
 
117
- def _serialize(node: Any, bad_field: str) -> Any:
118
- """Replace every leaf on ``bad_field`` with "matches everything", then simplify.
122
+ def _serialize(node: Any, matches: PathMatcher) -> Any:
123
+ """Replace every leaf whose path ``matches`` with "matches everything", then simplify.
119
124
 
120
125
  Repairing a query may only ever widen it. Two places make that non-obvious:
121
126
 
@@ -127,16 +132,16 @@ def _serialize(node: Any, bad_field: str) -> Any:
127
132
  kind = node[0]
128
133
  if kind == "leaf":
129
134
  term = node[1]
130
- if _is_leaf(term) and term[0] == bad_field:
135
+ if _is_leaf(term) and matches(term[0]):
131
136
  return _TRUE
132
137
  return [term]
133
138
  if kind == "not":
134
- if _mentions(node[1], bad_field):
139
+ if _mentions(node[1], matches):
135
140
  return _TRUE
136
- return [_UNARY_OP, *_serialize(node[1], bad_field)]
141
+ return [_UNARY_OP, *_serialize(node[1], matches)]
137
142
  _, operator, left_node, right_node = node
138
- left = _serialize(left_node, bad_field)
139
- right = _serialize(right_node, bad_field)
143
+ left = _serialize(left_node, matches)
144
+ right = _serialize(right_node, matches)
140
145
  if operator == "|":
141
146
  if left is _TRUE or right is _TRUE:
142
147
  return _TRUE
@@ -150,12 +155,14 @@ def _serialize(node: Any, bad_field: str) -> Any:
150
155
  return [operator, *left, *right]
151
156
 
152
157
 
153
- def strip_field_from_domain(domain: Any, bad_field: str) -> Any:
154
- """Remove every constraint on ``bad_field``, widening the domain and never narrowing it.
158
+ def strip_leaves_from_domain(domain: Any, matches: PathMatcher) -> Any:
159
+ """Remove every leaf whose field path ``matches``, widening the domain and never narrowing it.
155
160
 
156
- Every record the original domain matched still matches the result. That is the only
157
- property that makes an automatic repair defensible: returning extra rows is visible,
158
- losing rows the caller asked for is not.
161
+ A removed leaf means "matches everything", so every record the original domain matched
162
+ still matches the result. That is the only property that makes an automatic repair
163
+ defensible: returning extra rows is visible, losing rows the caller asked for is not.
164
+ ``matches`` receives the whole path, so a caller can remove ``product_id.detailed_type``
165
+ when Odoo rejected ``detailed_type`` on ``product.product``.
159
166
  """
160
167
  if not isinstance(domain, list) or not domain:
161
168
  return domain
@@ -164,7 +171,7 @@ def strip_field_from_domain(domain: Any, bad_field: str) -> Any:
164
171
  pos = 0
165
172
  while pos < len(domain):
166
173
  node, pos = _parse(domain, pos)
167
- serialized = _serialize(node, bad_field)
174
+ serialized = _serialize(node, matches)
168
175
  if serialized is not _TRUE:
169
176
  cleaned.extend(serialized)
170
177
  return cleaned
@@ -173,6 +180,14 @@ def strip_field_from_domain(domain: Any, bad_field: str) -> Any:
173
180
  return domain
174
181
 
175
182
 
183
+ def strip_field_from_domain(domain: Any, bad_field: str) -> Any:
184
+ """Remove every constraint on the field named exactly ``bad_field``, only ever widening.
185
+
186
+ See ``strip_leaves_from_domain``; dotted paths are matched whole, not by segment.
187
+ """
188
+ return strip_leaves_from_domain(domain, lambda path: path == bad_field)
189
+
190
+
176
191
  # ----- -w DSL -----
177
192
 
178
193
  _WORD_OPS = (
@@ -90,6 +90,36 @@ class OdooRepairedError(OdooError):
90
90
  default_code = "result_repaired"
91
91
 
92
92
 
93
+ class OdooFieldMissingError(OdooError):
94
+ """A field the query filters on is not available on this server, and was not removed.
95
+
96
+ Removing a leaf from a domain widens the query, so the rows would answer another
97
+ question. Raised instead of repairing unless the caller opted in (``strip_domain=True``).
98
+ Exit 2 like any other unknown field.
99
+ """
100
+
101
+ exit_code = 2
102
+ default_code = "field_missing"
103
+
104
+ def __init__(
105
+ self,
106
+ message: str,
107
+ *,
108
+ model: str,
109
+ field: str,
110
+ where: str = "domain",
111
+ code: str | None = None,
112
+ data: dict[str, Any] | None = None,
113
+ ) -> None:
114
+ self.model = model
115
+ self.field = field
116
+ self.where = where
117
+ super().__init__(message, code=code, data=data)
118
+
119
+ def to_dict(self) -> dict[str, Any]:
120
+ return {**super().to_dict(), "model": self.model, "field": self.field, "where": self.where}
121
+
122
+
93
123
  _BY_EXCEPTION_NAME: dict[str, type[OdooError]] = {
94
124
  "odoo.exceptions.AccessDenied": OdooAuthError,
95
125
  "odoo.exceptions.AccessError": OdooAccessError,
@@ -0,0 +1,161 @@
1
+ """search_read that repairs fields Odoo rejects and retries (version drift).
2
+
3
+ Dropping a field from ``fields`` or ``order`` changes the shape of the answer, never which
4
+ records it holds, so it is always repaired. Dropping one from the domain widens the query:
5
+ the caller asked for cash accounts and would get every account. That is only done when the
6
+ caller opts in with ``strip_domain=True``; otherwise ``OdooFieldMissingError`` is raised and
7
+ nothing is replayed.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from collections.abc import Callable
14
+ from typing import Any
15
+
16
+ from odoocli.client import AsyncOdooClient, Domain
17
+ from odoocli.domain import PathMatcher, strip_leaves_from_domain
18
+ from odoocli.errors import OdooError, OdooFieldMissingError
19
+
20
+ # "Invalid field 'mobile' on model 'res.partner'" (read), "Invalid field 'date_planned'",
21
+ # "Invalid field account.account.deprecated in condition" / "in leaf" (domain, 15 to 20).
22
+ _INVALID_FIELD_RE = re.compile(
23
+ r"Invalid field '?([\w.]+)'?(?: on model '?([\w.]+)'?)?", re.IGNORECASE
24
+ )
25
+ # "Cannot convert product.product.qty_available to SQL because it is not stored" and
26
+ # "Field 'is_storable' cannot be used in domain"
27
+ _NON_STORED_RE = re.compile(
28
+ r"Cannot convert ([\w.]+) to SQL|[Ff]ield '?([\w.]+)'? cannot be used in domain",
29
+ re.IGNORECASE,
30
+ )
31
+
32
+ Warn = Callable[[dict[str, Any]], None]
33
+
34
+
35
+ def _rejected(name: str, on_model: str | None, queried: str) -> tuple[str, str]:
36
+ """``(model, field)`` that Odoo rejected, from the name its error message printed.
37
+
38
+ ``account.account.deprecated`` is qualified: the model is everything before the last dot.
39
+ A bare name is taken to be on the queried model.
40
+ """
41
+ if on_model:
42
+ return on_model, name.rsplit(".", 1)[-1]
43
+ if "." in name:
44
+ model, field = name.rsplit(".", 1)
45
+ return model, field
46
+ return queried, name
47
+
48
+
49
+ def _path_matcher(queried: str, rejected_model: str, bad_field: str) -> PathMatcher:
50
+ """Which domain paths the rejection is about.
51
+
52
+ A rejection on the queried model is about the *first* segment of a path only:
53
+ ``name`` missing on ``sale.order.line`` says nothing about ``product_id.name``, and
54
+ matching it there would refuse, or strip, a filter that is perfectly valid.
55
+
56
+ A rejection naming another model (the qualified ``product.product.detailed_type`` form
57
+ Odoo prints for the model the failing segment was resolved on) can only come from a
58
+ deeper segment, so any segment after the first is matched. Segments are compared
59
+ exactly, so a missing ``type`` is never confused with ``move_type``.
60
+
61
+ Without the schema this cannot tell a self-referencing path (``parent_id.x`` on
62
+ ``res.partner``) from a root one; such a leaf is not matched and the original error is
63
+ raised, which is the safe way to be wrong.
64
+ """
65
+ if rejected_model == queried:
66
+ return lambda path: path.split(".")[0] == bad_field
67
+ return lambda path: bad_field in path.split(".")[1:]
68
+
69
+
70
+ def _in_domain(domain: Domain, matches: PathMatcher) -> bool:
71
+ """Whether a top-level leaf of ``domain`` has a path ``matches`` accepts."""
72
+ return any(
73
+ isinstance(term, list | tuple)
74
+ and len(term) == 3
75
+ and isinstance(term[0], str)
76
+ and matches(term[0])
77
+ for term in domain
78
+ )
79
+
80
+
81
+ def _field_missing(model: str, bad_field: str, cause: OdooError) -> OdooFieldMissingError:
82
+ return OdooFieldMissingError(
83
+ f"Field {model}.{bad_field} is not available in the domain on this Odoo version",
84
+ model=model,
85
+ field=bad_field,
86
+ where="domain",
87
+ data=cause.data,
88
+ )
89
+
90
+
91
+ def _strip_order(order: str, bad_field: str) -> str | None:
92
+ parts = [p.strip() for p in order.split(",") if p.strip() and p.strip().split()[0] != bad_field]
93
+ return ", ".join(parts) if parts else None
94
+
95
+
96
+ async def lenient_search_read(
97
+ client: AsyncOdooClient,
98
+ model: str,
99
+ domain: Domain | None,
100
+ fields: list[str] | None,
101
+ limit: int | None,
102
+ offset: int,
103
+ order: str | None,
104
+ *,
105
+ max_retries: int = 3,
106
+ strip_domain: bool = False,
107
+ on_warning: Warn | None = None,
108
+ ) -> list[dict[str, Any]]:
109
+ """Like ``client.search_read`` but removes rejected fields and retries.
110
+
111
+ A rejected field in ``fields`` or ``order`` is removed and the query replayed. A rejected
112
+ field in the domain raises ``OdooFieldMissingError`` before any replay, unless
113
+ ``strip_domain`` is true: then the leaves holding it are removed, dotted paths included
114
+ (the domain only ever widens, see ``strip_leaves_from_domain``) and the rows answer a
115
+ wider question than the one asked. ``_path_matcher`` decides which leaves hold it.
116
+
117
+ ``fields`` and ``order`` are only repaired for a rejection on the queried model itself.
118
+
119
+ Every removal is reported through ``on_warning`` as
120
+ ``{"warning": <kind>, "field": <name>, "from": ["fields" | "domain" | "order", ...]}``.
121
+ """
122
+ domain = list(domain or [])
123
+ fields = list(fields) if fields else None
124
+ for _ in range(max_retries + 1):
125
+ try:
126
+ return await client.search_read(model, domain, fields, limit, offset, order)
127
+ except OdooError as e:
128
+ text = e.message
129
+ invalid = _INVALID_FIELD_RE.search(text)
130
+ non_stored = None if invalid else _NON_STORED_RE.search(text)
131
+ if invalid:
132
+ kind, name, on_model = "invalid_field_removed", invalid.group(1), invalid.group(2)
133
+ elif non_stored:
134
+ kind, on_model = "non_stored_field_removed", None
135
+ name = non_stored.group(1) or non_stored.group(2) or ""
136
+ else:
137
+ raise
138
+ owner, bad = _rejected(name, on_model, model)
139
+ matches = _path_matcher(model, owner, bad)
140
+ in_domain = bool(bad) and _in_domain(domain, matches)
141
+ if in_domain and not strip_domain:
142
+ raise _field_missing(owner, bad, e) from e
143
+ on_root = owner == model
144
+ removed: list[str] = []
145
+ # A non-stored field reads fine, so it never has to leave ``fields``.
146
+ if invalid and on_root and fields and bad in fields:
147
+ fields = [f for f in fields if f != bad] or None
148
+ removed.append("fields")
149
+ if in_domain:
150
+ domain = strip_leaves_from_domain(domain, matches)
151
+ removed.append("domain")
152
+ if on_root and bad and order and bad in order:
153
+ order = _strip_order(order, bad)
154
+ removed.append("order")
155
+ if not removed:
156
+ raise
157
+ if on_warning:
158
+ on_warning({"warning": kind, "field": bad, "from": removed})
159
+ raise OdooError(
160
+ f"Gave up repairing the query after {max_retries} retries", code="retry_exhausted"
161
+ )
@@ -44,6 +44,7 @@ READ_SAFE_METHODS: frozenset[str] = frozenset(
44
44
  "search_count",
45
45
  "read",
46
46
  "read_group",
47
+ "formatted_read_group",
47
48
  "fields_get",
48
49
  "name_search",
49
50
  "name_get",
@@ -1,93 +0,0 @@
1
- """Opt-in search_read that strips fields Odoo rejects and retries (version drift)."""
2
-
3
- from __future__ import annotations
4
-
5
- import re
6
- from collections.abc import Callable
7
- from typing import Any
8
-
9
- from odoocli.client import AsyncOdooClient, Domain
10
- from odoocli.domain import strip_field_from_domain
11
- from odoocli.errors import OdooError
12
-
13
- # "Invalid field 'date_planned'" and "Invalid field account.account.deprecated in condition"
14
- _INVALID_FIELD_RE = re.compile(r"Invalid field '?([\w.]+)'?", re.IGNORECASE)
15
- # "Cannot convert qty_available to SQL because it is not stored" and
16
- # "Field 'is_storable' cannot be used in domain"
17
- _NON_STORED_RE = re.compile(
18
- r"Cannot convert ([\w.]+) to SQL|[Ff]ield '?([\w.]+)'? cannot be used in domain",
19
- re.IGNORECASE,
20
- )
21
-
22
- Warn = Callable[[dict[str, Any]], None]
23
-
24
-
25
- def _strip_order(order: str, bad_field: str) -> str | None:
26
- parts = [p.strip() for p in order.split(",") if p.strip() and p.strip().split()[0] != bad_field]
27
- return ", ".join(parts) if parts else None
28
-
29
-
30
- async def lenient_search_read(
31
- client: AsyncOdooClient,
32
- model: str,
33
- domain: Domain | None,
34
- fields: list[str] | None,
35
- limit: int | None,
36
- offset: int,
37
- order: str | None,
38
- *,
39
- max_retries: int = 3,
40
- on_warning: Warn | None = None,
41
- ) -> list[dict[str, Any]]:
42
- """Like ``client.search_read`` but removes rejected fields and retries.
43
-
44
- Every removal is reported through ``on_warning`` as
45
- ``{"warning": <kind>, "field": <name>, "from": ["fields" | "domain" | "order", ...]}``.
46
- """
47
- domain = list(domain or [])
48
- fields = list(fields) if fields else None
49
- for _ in range(max_retries + 1):
50
- try:
51
- return await client.search_read(model, domain, fields, limit, offset, order)
52
- except OdooError as e:
53
- text = e.message
54
- invalid = _INVALID_FIELD_RE.search(text)
55
- if invalid:
56
- bad = invalid.group(1).split(".")[-1]
57
- removed: list[str] = []
58
- if fields and bad in fields:
59
- fields = [f for f in fields if f != bad] or None
60
- removed.append("fields")
61
- if bad in str(domain):
62
- domain = strip_field_from_domain(domain, bad)
63
- removed.append("domain")
64
- if order and bad in order:
65
- order = _strip_order(order, bad)
66
- removed.append("order")
67
- if removed:
68
- if on_warning:
69
- on_warning(
70
- {"warning": "invalid_field_removed", "field": bad, "from": removed}
71
- )
72
- continue
73
- raise
74
- non_stored = _NON_STORED_RE.search(text)
75
- if non_stored:
76
- bad = (non_stored.group(1) or non_stored.group(2) or "").split(".")[-1]
77
- removed = []
78
- if bad and bad in str(domain):
79
- domain = strip_field_from_domain(domain, bad)
80
- removed.append("domain")
81
- if bad and order and bad in order:
82
- order = _strip_order(order, bad)
83
- removed.append("order")
84
- if removed:
85
- if on_warning:
86
- on_warning(
87
- {"warning": "non_stored_field_removed", "field": bad, "from": removed}
88
- )
89
- continue
90
- raise
91
- raise OdooError(
92
- f"Gave up repairing the query after {max_retries} retries", code="retry_exhausted"
93
- )
File without changes