odoo-agent-cli 0.4.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.4.0 → odoo_agent_cli-0.6.0}/PKG-INFO +55 -13
  2. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/README.md +54 -12
  3. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/SKILL.md +11 -5
  4. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/pyproject.toml +2 -1
  5. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/AGENT_GUIDE.md +11 -5
  6. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/__init__.py +4 -0
  7. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/_version.py +1 -1
  8. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/aliases.py +112 -1
  9. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/alias_cmd.py +5 -5
  10. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/app.py +31 -8
  11. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/profile_cmds.py +39 -5
  12. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/read_cmds.py +87 -19
  13. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/write_cmds.py +4 -4
  14. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/config.py +33 -4
  15. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/domain.py +69 -20
  16. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/errors.py +41 -0
  17. odoo_agent_cli-0.6.0/src/odoocli/lenient.py +161 -0
  18. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/security.py +1 -0
  19. odoo_agent_cli-0.4.0/src/odoocli/lenient.py +0 -93
  20. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/.gitignore +0 -0
  21. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/LICENSE +0 -0
  22. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/__main__.py +0 -0
  23. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/__init__.py +0 -0
  24. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/cache_cmds.py +0 -0
  25. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/guide_cmd.py +0 -0
  26. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/output.py +0 -0
  27. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/values.py +0 -0
  28. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/client.py +0 -0
  29. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/py.typed +0 -0
  30. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/schema.py +0 -0
  31. {odoo_agent_cli-0.4.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.4.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
 
@@ -67,7 +69,8 @@ odoo group invoices -w unpaid --by partner_id --sum amount_residual
67
69
  anything is sent, and the technical names keep working. Full documentation:
68
70
  [Organize-IT.github.io/odoo-cli](https://github.com/Organize-IT/odoo-cli/tree/main/docs).
69
71
 
70
- Prefer named connections? They live in a `0600` TOML file:
72
+ Prefer named connections? They live in a TOML file written owner-only where the platform
73
+ can enforce that — `odoo profile path --check` says whether it can:
71
74
 
72
75
  ```bash
73
76
  odoo profile add acme --url https://acme.odoo.com --db acme --login bot@acme.com \
@@ -84,7 +87,10 @@ First match wins, and the CLI never prompts:
84
87
  3. a profile named `default`
85
88
 
86
89
  Nothing found: exit code 3 with a message listing those three ways. A profile stores the key
87
- (`--api-key`) or points to an env var (`--api-key-env`). `odoo profile path` shows the file.
90
+ (`--api-key`) or points to an env var (`--api-key-env`). `odoo profile path` shows the file,
91
+ `--check` reports what its permissions are actually worth: mode 600 means owner-only on
92
+ POSIX and nothing at all on Windows, where `chmod` only toggles a read-only attribute. On
93
+ such a platform, prefer `--api-key-env` and keep the key in your secret manager.
88
94
 
89
95
  ## Output contract
90
96
 
@@ -96,6 +102,7 @@ Nothing found: exit code 3 with a message listing those three ways. A profile st
96
102
  | bad arguments | | `{"error": ...}` | 2 |
97
103
  | connection, auth, no profile | | `{"error": ...}` | 3 |
98
104
  | refused by a guard | | `{"error": ...}` | 4 |
105
+ | query repaired to run | rows | `{"error": ...}` | 5 |
99
106
  | write executed | result | `{"write": {"model", "method", "ids", "fields"}}` | 0 |
100
107
 
101
108
  Data is never humanised: many2one fields stay `[id, "name"]`, empty values stay `false`.
@@ -134,6 +141,31 @@ A bare word is looked up as a preset for the model: `-w overdue`, `-w unpaid`, `
134
141
  `-w confirmed`, `-w archived`. `odoo alias MODEL --presets` lists the ones that apply. A bare
135
142
  word that is not a preset exits 2 listing the ones that are.
136
143
 
144
+ ## Your own names
145
+
146
+ The shipped table covers what most tenants call things. A profile file adds the rest, and a
147
+ user entry replaces a built-in of the same name — your tenant knows its vocabulary better than
148
+ this tool does:
149
+
150
+ ```toml
151
+ [aliases.subscriptions]
152
+ model = "sale.subscription"
153
+ domain = [["stage_category", "=", "progress"]]
154
+ help = "Running subscriptions"
155
+
156
+ [aliases.invoices] # replaces the built-in
157
+ model = "account.move"
158
+ domain = [["move_type", "=", "out_invoice"], ["company_id", "=", 3]]
159
+
160
+ [presets.mine]
161
+ domain = [["user_id", "=", 7]]
162
+ models = ["crm.lead", "sale.order"]
163
+ ```
164
+
165
+ `odoo alias` marks each entry `builtin` or `config`. A malformed table fails the command with
166
+ exit 2 instead of being skipped: a filter you believe is applied and is not is exactly what
167
+ this mechanism exists to avoid. Dynamic dates work too — `@today`, `@month-start`, `@year-start`.
168
+
137
169
  ## Names and typos
138
170
 
139
171
  Read commands accept an alias in place of a technical model name (`invoices`, `customers`,
@@ -166,7 +198,10 @@ odoo group invoices -w overdue --by partner_id --sum amount_residual \
166
198
  odoo group invoices --by invoice_date:month --sum amount_total
167
199
  ```
168
200
 
169
- `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`.
170
205
 
171
206
  ## Writes
172
207
 
@@ -192,8 +227,9 @@ are refused unless `--include-sensitive`.
192
227
  many2one shapes) and recipes.
193
228
  - The same text ships as an [Agent Skill](https://github.com/Organize-IT/odoo-cli/blob/main/SKILL.md):
194
229
  `npx skills add Organize-IT/odoo-cli`.
195
- - `odoo search ... --lenient-fields` removes fields Odoo rejects and retries, with a warning on
196
- stderr. Exploration only.
230
+ - `odoo search ... --lenient-fields` removes fields Odoo rejects and retries. It prints the
231
+ rows, then exits **5**: they answer a wider question than the one you asked. Exploration
232
+ stays comfortable; a script that checks its exit codes cannot be fooled by it.
197
233
 
198
234
  ## Library
199
235
 
@@ -223,14 +259,17 @@ async with AsyncOdooClient(url, db, login, key) as odoo:
223
259
  ```
224
260
 
225
261
  Exceptions: `OdooError` (base, `.code`, `.message`, `.data`), `OdooConnectionError`,
226
- `OdooAuthError`, `OdooAccessError`, `OdooValidationError`, `OdooMissingError`.
262
+ `OdooAuthError`, `OdooAccessError`, `OdooValidationError`, `OdooMissingError`,
263
+ `OdooFieldMissingError`.
227
264
 
228
265
  Both clients accept `context={...}` (merged into every call; a per-call `context=` keyword
229
266
  wins), `verify_ssl=False` and `max_retries`. HTTP 429 is always retried with backoff and
230
267
  `Retry-After`; network errors, timeouts and HTTP 5xx are retried only for calls that cannot
231
268
  change data, so a `create` that timed out is never replayed. Logs go to the `odoocli.rpc`
232
- logger. Domain helpers live in `odoocli.domain`, guards in `odoocli.security`, the opt-in
233
- 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.
234
273
 
235
274
  ## Development
236
275
 
@@ -243,18 +282,21 @@ uv run pytest --cov --cov-report=term-missing # gated at 93% in CI
243
282
  uv run --group docs mkdocs serve # the documentation site
244
283
 
245
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
246
287
  ODOO_URL=http://localhost:8069 ODOO_DB=test ODOO_LOGIN=admin ODOO_API_KEY=admin \
247
288
  ODOO_ALLOW_WRITES=1 uv run pytest -m integration -o addopts=""
248
289
  docker compose -f docker/odoo-compose.yml down -v
249
290
  ```
250
291
 
251
292
  CI runs the unit suite on Python 3.11-3.13 and builds the docs on every PR; the integration
252
- 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
253
294
  published to PyPI on `v*` tags through trusted publishing, with every action pinned to a
254
295
  commit digest.
255
296
 
256
297
  `AGENTS.md` is the specification: layering, the contracts that may not change silently, and
257
- the definition of done. Read it before changing anything.
298
+ the definition of done. [docs/decisions/](docs/decisions/) records the decisions with a real
299
+ trade-off behind them. Read both before changing anything.
258
300
 
259
301
  ## License
260
302
 
@@ -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
 
@@ -40,7 +42,8 @@ odoo group invoices -w unpaid --by partner_id --sum amount_residual
40
42
  anything is sent, and the technical names keep working. Full documentation:
41
43
  [Organize-IT.github.io/odoo-cli](https://github.com/Organize-IT/odoo-cli/tree/main/docs).
42
44
 
43
- Prefer named connections? They live in a `0600` TOML file:
45
+ Prefer named connections? They live in a TOML file written owner-only where the platform
46
+ can enforce that — `odoo profile path --check` says whether it can:
44
47
 
45
48
  ```bash
46
49
  odoo profile add acme --url https://acme.odoo.com --db acme --login bot@acme.com \
@@ -57,7 +60,10 @@ First match wins, and the CLI never prompts:
57
60
  3. a profile named `default`
58
61
 
59
62
  Nothing found: exit code 3 with a message listing those three ways. A profile stores the key
60
- (`--api-key`) or points to an env var (`--api-key-env`). `odoo profile path` shows the file.
63
+ (`--api-key`) or points to an env var (`--api-key-env`). `odoo profile path` shows the file,
64
+ `--check` reports what its permissions are actually worth: mode 600 means owner-only on
65
+ POSIX and nothing at all on Windows, where `chmod` only toggles a read-only attribute. On
66
+ such a platform, prefer `--api-key-env` and keep the key in your secret manager.
61
67
 
62
68
  ## Output contract
63
69
 
@@ -69,6 +75,7 @@ Nothing found: exit code 3 with a message listing those three ways. A profile st
69
75
  | bad arguments | | `{"error": ...}` | 2 |
70
76
  | connection, auth, no profile | | `{"error": ...}` | 3 |
71
77
  | refused by a guard | | `{"error": ...}` | 4 |
78
+ | query repaired to run | rows | `{"error": ...}` | 5 |
72
79
  | write executed | result | `{"write": {"model", "method", "ids", "fields"}}` | 0 |
73
80
 
74
81
  Data is never humanised: many2one fields stay `[id, "name"]`, empty values stay `false`.
@@ -107,6 +114,31 @@ A bare word is looked up as a preset for the model: `-w overdue`, `-w unpaid`, `
107
114
  `-w confirmed`, `-w archived`. `odoo alias MODEL --presets` lists the ones that apply. A bare
108
115
  word that is not a preset exits 2 listing the ones that are.
109
116
 
117
+ ## Your own names
118
+
119
+ The shipped table covers what most tenants call things. A profile file adds the rest, and a
120
+ user entry replaces a built-in of the same name — your tenant knows its vocabulary better than
121
+ this tool does:
122
+
123
+ ```toml
124
+ [aliases.subscriptions]
125
+ model = "sale.subscription"
126
+ domain = [["stage_category", "=", "progress"]]
127
+ help = "Running subscriptions"
128
+
129
+ [aliases.invoices] # replaces the built-in
130
+ model = "account.move"
131
+ domain = [["move_type", "=", "out_invoice"], ["company_id", "=", 3]]
132
+
133
+ [presets.mine]
134
+ domain = [["user_id", "=", 7]]
135
+ models = ["crm.lead", "sale.order"]
136
+ ```
137
+
138
+ `odoo alias` marks each entry `builtin` or `config`. A malformed table fails the command with
139
+ exit 2 instead of being skipped: a filter you believe is applied and is not is exactly what
140
+ this mechanism exists to avoid. Dynamic dates work too — `@today`, `@month-start`, `@year-start`.
141
+
110
142
  ## Names and typos
111
143
 
112
144
  Read commands accept an alias in place of a technical model name (`invoices`, `customers`,
@@ -139,7 +171,10 @@ odoo group invoices -w overdue --by partner_id --sum amount_residual \
139
171
  odoo group invoices --by invoice_date:month --sum amount_total
140
172
  ```
141
173
 
142
- `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`.
143
178
 
144
179
  ## Writes
145
180
 
@@ -165,8 +200,9 @@ are refused unless `--include-sensitive`.
165
200
  many2one shapes) and recipes.
166
201
  - The same text ships as an [Agent Skill](https://github.com/Organize-IT/odoo-cli/blob/main/SKILL.md):
167
202
  `npx skills add Organize-IT/odoo-cli`.
168
- - `odoo search ... --lenient-fields` removes fields Odoo rejects and retries, with a warning on
169
- stderr. Exploration only.
203
+ - `odoo search ... --lenient-fields` removes fields Odoo rejects and retries. It prints the
204
+ rows, then exits **5**: they answer a wider question than the one you asked. Exploration
205
+ stays comfortable; a script that checks its exit codes cannot be fooled by it.
170
206
 
171
207
  ## Library
172
208
 
@@ -196,14 +232,17 @@ async with AsyncOdooClient(url, db, login, key) as odoo:
196
232
  ```
197
233
 
198
234
  Exceptions: `OdooError` (base, `.code`, `.message`, `.data`), `OdooConnectionError`,
199
- `OdooAuthError`, `OdooAccessError`, `OdooValidationError`, `OdooMissingError`.
235
+ `OdooAuthError`, `OdooAccessError`, `OdooValidationError`, `OdooMissingError`,
236
+ `OdooFieldMissingError`.
200
237
 
201
238
  Both clients accept `context={...}` (merged into every call; a per-call `context=` keyword
202
239
  wins), `verify_ssl=False` and `max_retries`. HTTP 429 is always retried with backoff and
203
240
  `Retry-After`; network errors, timeouts and HTTP 5xx are retried only for calls that cannot
204
241
  change data, so a `create` that timed out is never replayed. Logs go to the `odoocli.rpc`
205
- logger. Domain helpers live in `odoocli.domain`, guards in `odoocli.security`, the opt-in
206
- 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.
207
246
 
208
247
  ## Development
209
248
 
@@ -216,18 +255,21 @@ uv run pytest --cov --cov-report=term-missing # gated at 93% in CI
216
255
  uv run --group docs mkdocs serve # the documentation site
217
256
 
218
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
219
260
  ODOO_URL=http://localhost:8069 ODOO_DB=test ODOO_LOGIN=admin ODOO_API_KEY=admin \
220
261
  ODOO_ALLOW_WRITES=1 uv run pytest -m integration -o addopts=""
221
262
  docker compose -f docker/odoo-compose.yml down -v
222
263
  ```
223
264
 
224
265
  CI runs the unit suite on Python 3.11-3.13 and builds the docs on every PR; the integration
225
- 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
226
267
  published to PyPI on `v*` tags through trusted publishing, with every action pinned to a
227
268
  commit digest.
228
269
 
229
270
  `AGENTS.md` is the specification: layering, the contracts that may not change silently, and
230
- the definition of done. Read it before changing anything.
271
+ the definition of done. [docs/decisions/](docs/decisions/) records the decisions with a real
272
+ trade-off behind them. Read both before changing anything.
231
273
 
232
274
  ## License
233
275
 
@@ -18,6 +18,8 @@ Resolution order, first match wins:
18
18
  3. A profile named `default`
19
19
 
20
20
  Nothing resolved: exit code 3 and a message listing these three ways. The CLI never prompts.
21
+ `odoo profile path --check` says whether the stored key is really owner-only on this
22
+ platform; on Windows it is not, so prefer `--api-key-env` there.
21
23
  `ODOO_API_KEY` accepts an Odoo API key (preferred) or the user's password.
22
24
  Check a connection with `odoo info`. Self-signed on-prem server: `--insecure`
23
25
  (or `odoo profile add ... --no-verify-ssl`).
@@ -91,7 +93,8 @@ Refresh it after a module install or an Odoo upgrade with `odoo cache clear`.
91
93
  - Errors: one JSON object on stderr, `{"error": {"code": ..., "message": ..., "odoo": {...}}}`.
92
94
  - Warnings and write logs: one JSON object per line on stderr.
93
95
  - Exit codes: `0` ok, `1` Odoo raised, `2` bad usage, `3` connection or authentication,
94
- `4` refused by a guard (writes disabled, missing `--yes`, sensitive model).
96
+ `4` refused by a guard (writes disabled, missing `--yes`, sensitive model), `5` the query
97
+ was repaired to make it run, so the rows answer a wider question than the one you asked.
95
98
  - Values of fields named like `password`, `api_key`, `secret` are replaced by `[redacted]`
96
99
  unless `--no-redact`.
97
100
 
@@ -113,7 +116,9 @@ odoo cache path|list|clear the schema cache
113
116
  `MODEL` accepts an alias on every read command. `odoo group` runs `read_group`, so you
114
117
  get counts and totals per group without pulling the records:
115
118
  `odoo group invoices -w overdue --by partner_id --sum amount_residual`.
116
- 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`.
117
122
 
118
123
  Conditions (`-w`, repeatable, AND-ed together, combined with `--domain`):
119
124
 
@@ -171,10 +176,11 @@ One2many and many2many fields take Odoo commands, written as JSON in `-v` or `--
171
176
  5. Many2one values come back as `[id, name]`. Filter on them with the id
172
177
  (`-w partner_id=42`) or through a related field (`-w partner_id.name~acme`).
173
178
  6. Dates are strings, `YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS` in UTC.
174
- 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`
175
180
  became `company_ids`). If a field is rejected, `odoo fields` is the truth.
176
- `--lenient-fields` on `search` removes rejected fields and retries, with a warning on
177
- stderr; only use it for exploration, never in a script that relies on the result.
181
+ `--lenient-fields` on `search` removes rejected fields and retries. It prints the rows and
182
+ then exits 5, because they answer a wider question than the one you asked. Use it to
183
+ explore; if you keep it in a script, check the exit code.
178
184
  8. Never guess a model name: `odoo alias`, then `odoo models --like invoice`. A dotless
179
185
  name close to a known alias is reported as a typo instead of being sent.
180
186
  9. Sensitive models (`ir.config_parameter`, `ir.mail_server`, `res.users.apikeys`, `ir.cron`,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "odoo-agent-cli"
7
- version = "0.4.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"
@@ -45,6 +45,7 @@ dev = [
45
45
  "ruff>=0.6",
46
46
  "mypy>=1.11",
47
47
  "pytest-cov>=5",
48
+ "hypothesis>=6.168.0",
48
49
  ]
49
50
  docs = [
50
51
  "mkdocs>=1.6",
@@ -13,6 +13,8 @@ Resolution order, first match wins:
13
13
  3. A profile named `default`
14
14
 
15
15
  Nothing resolved: exit code 3 and a message listing these three ways. The CLI never prompts.
16
+ `odoo profile path --check` says whether the stored key is really owner-only on this
17
+ platform; on Windows it is not, so prefer `--api-key-env` there.
16
18
  `ODOO_API_KEY` accepts an Odoo API key (preferred) or the user's password.
17
19
  Check a connection with `odoo info`. Self-signed on-prem server: `--insecure`
18
20
  (or `odoo profile add ... --no-verify-ssl`).
@@ -86,7 +88,8 @@ Refresh it after a module install or an Odoo upgrade with `odoo cache clear`.
86
88
  - Errors: one JSON object on stderr, `{"error": {"code": ..., "message": ..., "odoo": {...}}}`.
87
89
  - Warnings and write logs: one JSON object per line on stderr.
88
90
  - Exit codes: `0` ok, `1` Odoo raised, `2` bad usage, `3` connection or authentication,
89
- `4` refused by a guard (writes disabled, missing `--yes`, sensitive model).
91
+ `4` refused by a guard (writes disabled, missing `--yes`, sensitive model), `5` the query
92
+ was repaired to make it run, so the rows answer a wider question than the one you asked.
90
93
  - Values of fields named like `password`, `api_key`, `secret` are replaced by `[redacted]`
91
94
  unless `--no-redact`.
92
95
 
@@ -108,7 +111,9 @@ odoo cache path|list|clear the schema cache
108
111
  `MODEL` accepts an alias on every read command. `odoo group` runs `read_group`, so you
109
112
  get counts and totals per group without pulling the records:
110
113
  `odoo group invoices -w overdue --by partner_id --sum amount_residual`.
111
- 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`.
112
117
 
113
118
  Conditions (`-w`, repeatable, AND-ed together, combined with `--domain`):
114
119
 
@@ -166,10 +171,11 @@ One2many and many2many fields take Odoo commands, written as JSON in `-v` or `--
166
171
  5. Many2one values come back as `[id, name]`. Filter on them with the id
167
172
  (`-w partner_id=42`) or through a related field (`-w partner_id.name~acme`).
168
173
  6. Dates are strings, `YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS` in UTC.
169
- 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`
170
175
  became `company_ids`). If a field is rejected, `odoo fields` is the truth.
171
- `--lenient-fields` on `search` removes rejected fields and retries, with a warning on
172
- stderr; only use it for exploration, never in a script that relies on the result.
176
+ `--lenient-fields` on `search` removes rejected fields and retries. It prints the rows and
177
+ then exits 5, because they answer a wider question than the one you asked. Use it to
178
+ explore; if you keep it in a script, check the exit code.
173
179
  8. Never guess a model name: `odoo alias`, then `odoo models --like invoice`. A dotless
174
180
  name close to a known alias is reported as a typo instead of being sent.
175
181
  9. Sensitive models (`ir.config_parameter`, `ir.mail_server`, `res.users.apikeys`, `ir.cron`,
@@ -7,8 +7,10 @@ from odoocli.errors import (
7
7
  OdooAuthError,
8
8
  OdooConnectionError,
9
9
  OdooError,
10
+ OdooFieldMissingError,
10
11
  OdooMissingError,
11
12
  OdooRefusedError,
13
+ OdooRepairedError,
12
14
  OdooUsageError,
13
15
  OdooValidationError,
14
16
  )
@@ -21,8 +23,10 @@ __all__ = [
21
23
  "OdooClient",
22
24
  "OdooConnectionError",
23
25
  "OdooError",
26
+ "OdooFieldMissingError",
24
27
  "OdooMissingError",
25
28
  "OdooRefusedError",
29
+ "OdooRepairedError",
26
30
  "OdooUsageError",
27
31
  "OdooValidationError",
28
32
  "__version__",
@@ -1,3 +1,3 @@
1
1
  """Single source of the package version (kept import-free to avoid cycles)."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.6.0"
@@ -10,14 +10,20 @@ Both are pure sugar: they expand to an ordinary domain before anything is sent,
10
10
  they never change the shape of the result, and the technical model name always
11
11
  keeps working. Aliases apply to read commands only — writing through a name
12
12
  that hides a filter would be a good way to create the wrong record.
13
+
14
+ The tables below ship with the tool. A profile file may add its own under
15
+ ``[aliases]`` and ``[presets]``; a user entry with a built-in name replaces it,
16
+ because the tenant knows its own vocabulary better than this file does.
13
17
  """
14
18
 
15
19
  from __future__ import annotations
16
20
 
17
21
  import difflib
22
+ import tomllib
18
23
  from collections.abc import Mapping
19
24
  from dataclasses import dataclass, field
20
25
  from datetime import date
26
+ from pathlib import Path
21
27
  from typing import Any
22
28
 
23
29
  # Dynamic operands, substituted when a preset is expanded.
@@ -141,7 +147,112 @@ PRESETS: Mapping[str, Preset] = {
141
147
  }
142
148
 
143
149
 
144
- # ----- lookup -----
150
+ # ----- registry -----
151
+
152
+
153
+ @dataclass(frozen=True, slots=True)
154
+ class Registry:
155
+ """The alias and preset tables in force for one invocation."""
156
+
157
+ aliases: Mapping[str, Alias]
158
+ presets: Mapping[str, Preset]
159
+
160
+ def resolve(self, name: str) -> Alias | None:
161
+ return self.aliases.get(name.strip().lower())
162
+
163
+ def resolve_model(self, name: str) -> str:
164
+ alias = self.resolve(name)
165
+ return alias.model if alias else name
166
+
167
+ def suggest(self, name: str) -> list[str]:
168
+ return difflib.get_close_matches(
169
+ name.strip().lower(), sorted(self.aliases), n=3, cutoff=0.6
170
+ )
171
+
172
+ def preset(self, model: str, name: str) -> Preset | None:
173
+ found = self.presets.get(name.strip().lower())
174
+ if found is None or (found.models and model not in found.models):
175
+ return None
176
+ return found
177
+
178
+ def presets_for(self, model: str | None) -> dict[str, Preset]:
179
+ if model is None:
180
+ return dict(self.presets)
181
+ return {n: p for n, p in self.presets.items() if not p.models or model in p.models}
182
+
183
+ def expand(self, model: str, token: str, today: date | None = None) -> list[list[Any]] | None:
184
+ found = self.preset(model, token)
185
+ return None if found is None else found.clauses(today or date.today())
186
+
187
+ def rows(self) -> list[dict[str, Any]]:
188
+ """Table-friendly listing, marking which entries came from the config file."""
189
+ return [
190
+ {
191
+ "alias": name,
192
+ "model": alias.model,
193
+ "filter": ", ".join(f"{f} {o} {v}" for f, o, v in alias.domain),
194
+ "presets": ", ".join(sorted(self.presets_for(alias.model))),
195
+ "source": "config" if name not in ALIASES or ALIASES[name] != alias else "builtin",
196
+ "help": alias.help,
197
+ }
198
+ for name, alias in sorted(self.aliases.items())
199
+ ]
200
+
201
+
202
+ BUILTIN = Registry(ALIASES, PRESETS)
203
+
204
+
205
+ def _leaves(raw: Any, where: str) -> tuple[tuple[str, str, Any], ...]:
206
+ if raw is None:
207
+ return ()
208
+ if not isinstance(raw, list):
209
+ raise ValueError(f"{where}: 'domain' must be a list of [field, operator, value]")
210
+ out: list[tuple[str, str, Any]] = []
211
+ for leaf in raw:
212
+ if not isinstance(leaf, list | tuple) or len(leaf) != 3 or not isinstance(leaf[0], str):
213
+ raise ValueError(
214
+ f"{where}: every clause must be [field, operator, value], got {leaf!r}"
215
+ )
216
+ out.append((str(leaf[0]), str(leaf[1]), leaf[2]))
217
+ return tuple(out)
218
+
219
+
220
+ def from_config(path: Path) -> Registry:
221
+ """Merge ``[aliases]`` and ``[presets]`` from a profile file over the built-in tables.
222
+
223
+ A malformed entry raises rather than being skipped: a filter the caller believes is
224
+ applied and is not is exactly the failure this whole mechanism exists to avoid.
225
+ """
226
+ try:
227
+ raw = tomllib.loads(path.read_text(encoding="utf-8"))
228
+ except (OSError, ValueError):
229
+ return BUILTIN
230
+ user_aliases = dict(ALIASES)
231
+ for name, table in (raw.get("aliases") or {}).items():
232
+ if not isinstance(table, dict):
233
+ continue
234
+ model = table.get("model")
235
+ if not isinstance(model, str) or "." not in model:
236
+ raise ValueError(f"alias '{name}': 'model' must be a technical Odoo model name")
237
+ user_aliases[name.strip().lower()] = Alias(
238
+ model=model,
239
+ domain=_leaves(table.get("domain"), f"alias '{name}'"),
240
+ help=str(table.get("help", "")),
241
+ )
242
+ user_presets = dict(PRESETS)
243
+ for name, table in (raw.get("presets") or {}).items():
244
+ if not isinstance(table, dict):
245
+ continue
246
+ models = table.get("models") or []
247
+ user_presets[name.strip().lower()] = Preset(
248
+ domain=_leaves(table.get("domain"), f"preset '{name}'"),
249
+ help=str(table.get("help", "")),
250
+ models=tuple(str(m) for m in models) if isinstance(models, list) else (),
251
+ )
252
+ return Registry(user_aliases, user_presets)
253
+
254
+
255
+ # ----- lookup (the built-in tables) -----
145
256
 
146
257
 
147
258
  def resolve(name: str) -> Alias | None:
@@ -6,8 +6,7 @@ from typing import Any
6
6
 
7
7
  import typer
8
8
 
9
- from odoocli import aliases
10
- from odoocli.cli.app import app, emit
9
+ from odoocli.cli.app import app, emit, session
11
10
 
12
11
 
13
12
  @app.command("alias")
@@ -21,8 +20,9 @@ def alias_cmd(
21
20
  ),
22
21
  ) -> None:
23
22
  """Model aliases ('invoices' -> account.move) and presets ('-w overdue'). Offline."""
23
+ registry = session(ctx).registry()
24
24
  if presets:
25
- model = aliases.resolve_model(name) if name else None
25
+ model = registry.resolve_model(name) if name else None
26
26
  rows: list[dict[str, Any]] = [
27
27
  {
28
28
  "preset": preset_name,
@@ -30,11 +30,11 @@ def alias_cmd(
30
30
  "domain": " and ".join(f"{f} {o} {v}" for f, o, v in preset.domain),
31
31
  "help": preset.help,
32
32
  }
33
- for preset_name, preset in sorted(aliases.presets_for(model).items())
33
+ for preset_name, preset in sorted(registry.presets_for(model).items())
34
34
  ]
35
35
  emit(ctx, rows)
36
36
  return
37
- listing = aliases.alias_rows()
37
+ listing = registry.rows()
38
38
  if name:
39
39
  needle = name.lower()
40
40
  listing = [r for r in listing if needle in r["alias"] or needle in r["model"]]