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.
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/PKG-INFO +18 -8
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/README.md +17 -7
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/SKILL.md +4 -2
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/pyproject.toml +1 -1
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/AGENT_GUIDE.md +4 -2
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/__init__.py +2 -0
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/_version.py +1 -1
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/read_cmds.py +59 -10
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/domain.py +32 -17
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/errors.py +30 -0
- odoo_agent_cli-0.6.0/src/odoocli/lenient.py +161 -0
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/security.py +1 -0
- odoo_agent_cli-0.5.0/src/odoocli/lenient.py +0 -93
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/.gitignore +0 -0
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/LICENSE +0 -0
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/__main__.py +0 -0
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/aliases.py +0 -0
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/__init__.py +0 -0
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/alias_cmd.py +0 -0
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/app.py +0 -0
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/cache_cmds.py +0 -0
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/guide_cmd.py +0 -0
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/output.py +0 -0
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/profile_cmds.py +0 -0
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/values.py +0 -0
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/write_cmds.py +0 -0
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/client.py +0 -0
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/config.py +0 -0
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/py.typed +0 -0
- {odoo_agent_cli-0.5.0 → odoo_agent_cli-0.6.0}/src/odoocli/schema.py +0 -0
- {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.
|
|
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
|
|
47
|
-
|
|
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
|
|
264
|
-
|
|
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
|
|
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
|
|
20
|
-
|
|
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
|
|
237
|
-
|
|
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
|
|
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
|
|
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
|
|
@@ -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
|
|
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",
|
|
@@ -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,
|
|
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
|
-
|
|
292
|
+
page: dict[str, Any] = {}
|
|
285
293
|
if limit is not None:
|
|
286
|
-
|
|
294
|
+
page["limit"] = limit
|
|
287
295
|
if offset:
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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]
|
|
116
|
+
return bool(_is_leaf(term) and matches(term[0]))
|
|
112
117
|
if kind == "not":
|
|
113
|
-
return _mentions(node[1],
|
|
114
|
-
return _mentions(node[2],
|
|
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,
|
|
118
|
-
"""Replace every leaf
|
|
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]
|
|
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],
|
|
139
|
+
if _mentions(node[1], matches):
|
|
135
140
|
return _TRUE
|
|
136
|
-
return [_UNARY_OP, *_serialize(node[1],
|
|
141
|
+
return [_UNARY_OP, *_serialize(node[1], matches)]
|
|
137
142
|
_, operator, left_node, right_node = node
|
|
138
|
-
left = _serialize(left_node,
|
|
139
|
-
right = _serialize(right_node,
|
|
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
|
|
154
|
-
"""Remove every
|
|
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
|
-
|
|
157
|
-
property that makes an automatic repair
|
|
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,
|
|
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
|
+
)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|