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.
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/PKG-INFO +55 -13
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/README.md +54 -12
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/SKILL.md +11 -5
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/pyproject.toml +2 -1
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/AGENT_GUIDE.md +11 -5
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/__init__.py +4 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/_version.py +1 -1
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/aliases.py +112 -1
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/alias_cmd.py +5 -5
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/app.py +31 -8
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/profile_cmds.py +39 -5
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/read_cmds.py +87 -19
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/write_cmds.py +4 -4
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/config.py +33 -4
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/domain.py +69 -20
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/errors.py +41 -0
- odoo_agent_cli-0.6.0/src/odoocli/lenient.py +161 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/security.py +1 -0
- odoo_agent_cli-0.4.0/src/odoocli/lenient.py +0 -93
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/.gitignore +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/LICENSE +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/__main__.py +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/__init__.py +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/cache_cmds.py +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/guide_cmd.py +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/output.py +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/cli/values.py +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/client.py +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/py.typed +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.6.0}/src/odoocli/schema.py +0 -0
- {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.
|
|
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
|
|
|
@@ -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
|
|
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
|
|
196
|
-
|
|
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
|
|
233
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
|
@@ -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
|
|
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
|
|
169
|
-
|
|
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
|
|
206
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
177
|
-
|
|
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.
|
|
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
|
|
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
|
|
172
|
-
|
|
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__",
|
|
@@ -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
|
-
# -----
|
|
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
|
|
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 =
|
|
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(
|
|
33
|
+
for preset_name, preset in sorted(registry.presets_for(model).items())
|
|
34
34
|
]
|
|
35
35
|
emit(ctx, rows)
|
|
36
36
|
return
|
|
37
|
-
listing =
|
|
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"]]
|