odoo-agent-cli 0.4.0__tar.gz → 0.5.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.5.0}/PKG-INFO +38 -6
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/README.md +37 -5
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/SKILL.md +7 -3
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/pyproject.toml +2 -1
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/AGENT_GUIDE.md +7 -3
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/__init__.py +2 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/_version.py +1 -1
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/aliases.py +112 -1
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/alias_cmd.py +5 -5
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/app.py +31 -8
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/profile_cmds.py +39 -5
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/read_cmds.py +30 -11
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/write_cmds.py +4 -4
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/config.py +33 -4
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/domain.py +48 -14
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/errors.py +11 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/.gitignore +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/LICENSE +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/__main__.py +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/__init__.py +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/cache_cmds.py +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/guide_cmd.py +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/output.py +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/values.py +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/client.py +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/lenient.py +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/py.typed +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/schema.py +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/security.py +0 -0
- {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.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.5.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
|
|
@@ -67,7 +67,8 @@ odoo group invoices -w unpaid --by partner_id --sum amount_residual
|
|
|
67
67
|
anything is sent, and the technical names keep working. Full documentation:
|
|
68
68
|
[Organize-IT.github.io/odoo-cli](https://github.com/Organize-IT/odoo-cli/tree/main/docs).
|
|
69
69
|
|
|
70
|
-
Prefer named connections? They live in a
|
|
70
|
+
Prefer named connections? They live in a TOML file written owner-only where the platform
|
|
71
|
+
can enforce that — `odoo profile path --check` says whether it can:
|
|
71
72
|
|
|
72
73
|
```bash
|
|
73
74
|
odoo profile add acme --url https://acme.odoo.com --db acme --login bot@acme.com \
|
|
@@ -84,7 +85,10 @@ First match wins, and the CLI never prompts:
|
|
|
84
85
|
3. a profile named `default`
|
|
85
86
|
|
|
86
87
|
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
|
|
88
|
+
(`--api-key`) or points to an env var (`--api-key-env`). `odoo profile path` shows the file,
|
|
89
|
+
`--check` reports what its permissions are actually worth: mode 600 means owner-only on
|
|
90
|
+
POSIX and nothing at all on Windows, where `chmod` only toggles a read-only attribute. On
|
|
91
|
+
such a platform, prefer `--api-key-env` and keep the key in your secret manager.
|
|
88
92
|
|
|
89
93
|
## Output contract
|
|
90
94
|
|
|
@@ -96,6 +100,7 @@ Nothing found: exit code 3 with a message listing those three ways. A profile st
|
|
|
96
100
|
| bad arguments | | `{"error": ...}` | 2 |
|
|
97
101
|
| connection, auth, no profile | | `{"error": ...}` | 3 |
|
|
98
102
|
| refused by a guard | | `{"error": ...}` | 4 |
|
|
103
|
+
| query repaired to run | rows | `{"error": ...}` | 5 |
|
|
99
104
|
| write executed | result | `{"write": {"model", "method", "ids", "fields"}}` | 0 |
|
|
100
105
|
|
|
101
106
|
Data is never humanised: many2one fields stay `[id, "name"]`, empty values stay `false`.
|
|
@@ -134,6 +139,31 @@ A bare word is looked up as a preset for the model: `-w overdue`, `-w unpaid`, `
|
|
|
134
139
|
`-w confirmed`, `-w archived`. `odoo alias MODEL --presets` lists the ones that apply. A bare
|
|
135
140
|
word that is not a preset exits 2 listing the ones that are.
|
|
136
141
|
|
|
142
|
+
## Your own names
|
|
143
|
+
|
|
144
|
+
The shipped table covers what most tenants call things. A profile file adds the rest, and a
|
|
145
|
+
user entry replaces a built-in of the same name — your tenant knows its vocabulary better than
|
|
146
|
+
this tool does:
|
|
147
|
+
|
|
148
|
+
```toml
|
|
149
|
+
[aliases.subscriptions]
|
|
150
|
+
model = "sale.subscription"
|
|
151
|
+
domain = [["stage_category", "=", "progress"]]
|
|
152
|
+
help = "Running subscriptions"
|
|
153
|
+
|
|
154
|
+
[aliases.invoices] # replaces the built-in
|
|
155
|
+
model = "account.move"
|
|
156
|
+
domain = [["move_type", "=", "out_invoice"], ["company_id", "=", 3]]
|
|
157
|
+
|
|
158
|
+
[presets.mine]
|
|
159
|
+
domain = [["user_id", "=", 7]]
|
|
160
|
+
models = ["crm.lead", "sale.order"]
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`odoo alias` marks each entry `builtin` or `config`. A malformed table fails the command with
|
|
164
|
+
exit 2 instead of being skipped: a filter you believe is applied and is not is exactly what
|
|
165
|
+
this mechanism exists to avoid. Dynamic dates work too — `@today`, `@month-start`, `@year-start`.
|
|
166
|
+
|
|
137
167
|
## Names and typos
|
|
138
168
|
|
|
139
169
|
Read commands accept an alias in place of a technical model name (`invoices`, `customers`,
|
|
@@ -192,8 +222,9 @@ are refused unless `--include-sensitive`.
|
|
|
192
222
|
many2one shapes) and recipes.
|
|
193
223
|
- The same text ships as an [Agent Skill](https://github.com/Organize-IT/odoo-cli/blob/main/SKILL.md):
|
|
194
224
|
`npx skills add Organize-IT/odoo-cli`.
|
|
195
|
-
- `odoo search ... --lenient-fields` removes fields Odoo rejects and retries
|
|
196
|
-
|
|
225
|
+
- `odoo search ... --lenient-fields` removes fields Odoo rejects and retries. It prints the
|
|
226
|
+
rows, then exits **5**: they answer a wider question than the one you asked. Exploration
|
|
227
|
+
stays comfortable; a script that checks its exit codes cannot be fooled by it.
|
|
197
228
|
|
|
198
229
|
## Library
|
|
199
230
|
|
|
@@ -254,7 +285,8 @@ published to PyPI on `v*` tags through trusted publishing, with every action pin
|
|
|
254
285
|
commit digest.
|
|
255
286
|
|
|
256
287
|
`AGENTS.md` is the specification: layering, the contracts that may not change silently, and
|
|
257
|
-
the definition of done.
|
|
288
|
+
the definition of done. [docs/decisions/](docs/decisions/) records the decisions with a real
|
|
289
|
+
trade-off behind them. Read both before changing anything.
|
|
258
290
|
|
|
259
291
|
## License
|
|
260
292
|
|
|
@@ -40,7 +40,8 @@ odoo group invoices -w unpaid --by partner_id --sum amount_residual
|
|
|
40
40
|
anything is sent, and the technical names keep working. Full documentation:
|
|
41
41
|
[Organize-IT.github.io/odoo-cli](https://github.com/Organize-IT/odoo-cli/tree/main/docs).
|
|
42
42
|
|
|
43
|
-
Prefer named connections? They live in a
|
|
43
|
+
Prefer named connections? They live in a TOML file written owner-only where the platform
|
|
44
|
+
can enforce that — `odoo profile path --check` says whether it can:
|
|
44
45
|
|
|
45
46
|
```bash
|
|
46
47
|
odoo profile add acme --url https://acme.odoo.com --db acme --login bot@acme.com \
|
|
@@ -57,7 +58,10 @@ First match wins, and the CLI never prompts:
|
|
|
57
58
|
3. a profile named `default`
|
|
58
59
|
|
|
59
60
|
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
|
|
61
|
+
(`--api-key`) or points to an env var (`--api-key-env`). `odoo profile path` shows the file,
|
|
62
|
+
`--check` reports what its permissions are actually worth: mode 600 means owner-only on
|
|
63
|
+
POSIX and nothing at all on Windows, where `chmod` only toggles a read-only attribute. On
|
|
64
|
+
such a platform, prefer `--api-key-env` and keep the key in your secret manager.
|
|
61
65
|
|
|
62
66
|
## Output contract
|
|
63
67
|
|
|
@@ -69,6 +73,7 @@ Nothing found: exit code 3 with a message listing those three ways. A profile st
|
|
|
69
73
|
| bad arguments | | `{"error": ...}` | 2 |
|
|
70
74
|
| connection, auth, no profile | | `{"error": ...}` | 3 |
|
|
71
75
|
| refused by a guard | | `{"error": ...}` | 4 |
|
|
76
|
+
| query repaired to run | rows | `{"error": ...}` | 5 |
|
|
72
77
|
| write executed | result | `{"write": {"model", "method", "ids", "fields"}}` | 0 |
|
|
73
78
|
|
|
74
79
|
Data is never humanised: many2one fields stay `[id, "name"]`, empty values stay `false`.
|
|
@@ -107,6 +112,31 @@ A bare word is looked up as a preset for the model: `-w overdue`, `-w unpaid`, `
|
|
|
107
112
|
`-w confirmed`, `-w archived`. `odoo alias MODEL --presets` lists the ones that apply. A bare
|
|
108
113
|
word that is not a preset exits 2 listing the ones that are.
|
|
109
114
|
|
|
115
|
+
## Your own names
|
|
116
|
+
|
|
117
|
+
The shipped table covers what most tenants call things. A profile file adds the rest, and a
|
|
118
|
+
user entry replaces a built-in of the same name — your tenant knows its vocabulary better than
|
|
119
|
+
this tool does:
|
|
120
|
+
|
|
121
|
+
```toml
|
|
122
|
+
[aliases.subscriptions]
|
|
123
|
+
model = "sale.subscription"
|
|
124
|
+
domain = [["stage_category", "=", "progress"]]
|
|
125
|
+
help = "Running subscriptions"
|
|
126
|
+
|
|
127
|
+
[aliases.invoices] # replaces the built-in
|
|
128
|
+
model = "account.move"
|
|
129
|
+
domain = [["move_type", "=", "out_invoice"], ["company_id", "=", 3]]
|
|
130
|
+
|
|
131
|
+
[presets.mine]
|
|
132
|
+
domain = [["user_id", "=", 7]]
|
|
133
|
+
models = ["crm.lead", "sale.order"]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`odoo alias` marks each entry `builtin` or `config`. A malformed table fails the command with
|
|
137
|
+
exit 2 instead of being skipped: a filter you believe is applied and is not is exactly what
|
|
138
|
+
this mechanism exists to avoid. Dynamic dates work too — `@today`, `@month-start`, `@year-start`.
|
|
139
|
+
|
|
110
140
|
## Names and typos
|
|
111
141
|
|
|
112
142
|
Read commands accept an alias in place of a technical model name (`invoices`, `customers`,
|
|
@@ -165,8 +195,9 @@ are refused unless `--include-sensitive`.
|
|
|
165
195
|
many2one shapes) and recipes.
|
|
166
196
|
- The same text ships as an [Agent Skill](https://github.com/Organize-IT/odoo-cli/blob/main/SKILL.md):
|
|
167
197
|
`npx skills add Organize-IT/odoo-cli`.
|
|
168
|
-
- `odoo search ... --lenient-fields` removes fields Odoo rejects and retries
|
|
169
|
-
|
|
198
|
+
- `odoo search ... --lenient-fields` removes fields Odoo rejects and retries. It prints the
|
|
199
|
+
rows, then exits **5**: they answer a wider question than the one you asked. Exploration
|
|
200
|
+
stays comfortable; a script that checks its exit codes cannot be fooled by it.
|
|
170
201
|
|
|
171
202
|
## Library
|
|
172
203
|
|
|
@@ -227,7 +258,8 @@ published to PyPI on `v*` tags through trusted publishing, with every action pin
|
|
|
227
258
|
commit digest.
|
|
228
259
|
|
|
229
260
|
`AGENTS.md` is the specification: layering, the contracts that may not change silently, and
|
|
230
|
-
the definition of done.
|
|
261
|
+
the definition of done. [docs/decisions/](docs/decisions/) records the decisions with a real
|
|
262
|
+
trade-off behind them. Read both before changing anything.
|
|
231
263
|
|
|
232
264
|
## License
|
|
233
265
|
|
|
@@ -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
|
|
|
@@ -173,8 +176,9 @@ One2many and many2many fields take Odoo commands, written as JSON in `-v` or `--
|
|
|
173
176
|
6. Dates are strings, `YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS` in UTC.
|
|
174
177
|
7. Field names drift between Odoo 17, 18 and 19 (for example `account.account.company_id`
|
|
175
178
|
became `company_ids`). If a field is rejected, `odoo fields` is the truth.
|
|
176
|
-
`--lenient-fields` on `search` removes rejected fields and retries
|
|
177
|
-
|
|
179
|
+
`--lenient-fields` on `search` removes rejected fields and retries. It prints the rows and
|
|
180
|
+
then exits 5, because they answer a wider question than the one you asked. Use it to
|
|
181
|
+
explore; if you keep it in a script, check the exit code.
|
|
178
182
|
8. Never guess a model name: `odoo alias`, then `odoo models --like invoice`. A dotless
|
|
179
183
|
name close to a known alias is reported as a typo instead of being sent.
|
|
180
184
|
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.5.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
|
|
|
@@ -168,8 +171,9 @@ One2many and many2many fields take Odoo commands, written as JSON in `-v` or `--
|
|
|
168
171
|
6. Dates are strings, `YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS` in UTC.
|
|
169
172
|
7. Field names drift between Odoo 17, 18 and 19 (for example `account.account.company_id`
|
|
170
173
|
became `company_ids`). If a field is rejected, `odoo fields` is the truth.
|
|
171
|
-
`--lenient-fields` on `search` removes rejected fields and retries
|
|
172
|
-
|
|
174
|
+
`--lenient-fields` on `search` removes rejected fields and retries. It prints the rows and
|
|
175
|
+
then exits 5, because they answer a wider question than the one you asked. Use it to
|
|
176
|
+
explore; if you keep it in a script, check the exit code.
|
|
173
177
|
8. Never guess a model name: `odoo alias`, then `odoo models --like invoice`. A dotless
|
|
174
178
|
name close to a known alias is reported as a typo instead of being sent.
|
|
175
179
|
9. Sensitive models (`ir.config_parameter`, `ir.mail_server`, `res.users.apikeys`, `ir.cron`,
|
|
@@ -9,6 +9,7 @@ from odoocli.errors import (
|
|
|
9
9
|
OdooError,
|
|
10
10
|
OdooMissingError,
|
|
11
11
|
OdooRefusedError,
|
|
12
|
+
OdooRepairedError,
|
|
12
13
|
OdooUsageError,
|
|
13
14
|
OdooValidationError,
|
|
14
15
|
)
|
|
@@ -23,6 +24,7 @@ __all__ = [
|
|
|
23
24
|
"OdooError",
|
|
24
25
|
"OdooMissingError",
|
|
25
26
|
"OdooRefusedError",
|
|
27
|
+
"OdooRepairedError",
|
|
26
28
|
"OdooUsageError",
|
|
27
29
|
"OdooValidationError",
|
|
28
30
|
"__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"]]
|
|
@@ -89,6 +89,19 @@ def hoist_global_options(args: list[str], value_options: frozenset[str] = frozen
|
|
|
89
89
|
|
|
90
90
|
|
|
91
91
|
class _RootGroup(TyperGroup):
|
|
92
|
+
def invoke(self, ctx: Any) -> object:
|
|
93
|
+
"""Map every OdooError to its exit code, not only those raised inside ``run()``.
|
|
94
|
+
|
|
95
|
+
Offline commands (``alias``, ``cache``) never open a client, so without this they
|
|
96
|
+
would escape as a traceback and exit 1 whatever the error said.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
return super().invoke(ctx)
|
|
100
|
+
except OdooError as e:
|
|
101
|
+
session_obj = ctx.obj if isinstance(ctx.obj, Session) else None
|
|
102
|
+
fail(e, bool(session_obj is not None and session_obj.verbose))
|
|
103
|
+
raise # unreachable: fail() raises typer.Exit
|
|
104
|
+
|
|
92
105
|
def parse_args(self, ctx: Any, args: list[str]) -> list[str]:
|
|
93
106
|
return super().parse_args(ctx, hoist_global_options(args, self._value_options(ctx, args)))
|
|
94
107
|
|
|
@@ -154,10 +167,20 @@ class Session:
|
|
|
154
167
|
env: Mapping[str, str] = field(default_factory=lambda: dict(os.environ))
|
|
155
168
|
config: Path = field(default_factory=lambda: config_path(os.environ))
|
|
156
169
|
_store: SchemaStore | None = field(default=None, repr=False, compare=False)
|
|
170
|
+
_registry: aliases.Registry | None = field(default=None, repr=False, compare=False)
|
|
157
171
|
|
|
158
172
|
def profile(self) -> Profile:
|
|
159
173
|
return resolve_profile(self.profile_name, self.env, self.config)
|
|
160
174
|
|
|
175
|
+
def registry(self) -> aliases.Registry:
|
|
176
|
+
"""Alias and preset tables, built-ins merged with anything the config file adds."""
|
|
177
|
+
if self._registry is None:
|
|
178
|
+
try:
|
|
179
|
+
self._registry = aliases.from_config(self.config)
|
|
180
|
+
except ValueError as e:
|
|
181
|
+
raise OdooUsageError(f"{self.config}: {e}", code="invalid_alias_table") from e
|
|
182
|
+
return self._registry
|
|
183
|
+
|
|
161
184
|
def store(self, profile: Profile) -> SchemaStore:
|
|
162
185
|
"""Schema cache for this connection, created once per command."""
|
|
163
186
|
if self._store is None:
|
|
@@ -322,9 +345,9 @@ def fail(err: OdooError, verbose: bool) -> None:
|
|
|
322
345
|
raise typer.Exit(err.exit_code)
|
|
323
346
|
|
|
324
347
|
|
|
325
|
-
def _unknown_name(name: str) -> OdooUsageError | None:
|
|
348
|
+
def _unknown_name(registry: aliases.Registry, name: str) -> OdooUsageError | None:
|
|
326
349
|
"""A name with no dot that is close to an alias is almost certainly a typo."""
|
|
327
|
-
close =
|
|
350
|
+
close = registry.suggest(name)
|
|
328
351
|
if "." in name or not close:
|
|
329
352
|
return None
|
|
330
353
|
return OdooUsageError(
|
|
@@ -335,22 +358,22 @@ def _unknown_name(name: str) -> OdooUsageError | None:
|
|
|
335
358
|
)
|
|
336
359
|
|
|
337
360
|
|
|
338
|
-
def read_target(name: str) -> tuple[str, list[list[Any]]]:
|
|
361
|
+
def read_target(registry: aliases.Registry, name: str) -> tuple[str, list[list[Any]]]:
|
|
339
362
|
"""Resolve a model name or alias for a read command, with its base clauses."""
|
|
340
|
-
alias =
|
|
363
|
+
alias = registry.resolve(name)
|
|
341
364
|
if alias is not None:
|
|
342
365
|
return alias.model, alias.clauses()
|
|
343
|
-
problem = _unknown_name(name)
|
|
366
|
+
problem = _unknown_name(registry, name)
|
|
344
367
|
if problem is not None:
|
|
345
368
|
raise problem
|
|
346
369
|
return name, []
|
|
347
370
|
|
|
348
371
|
|
|
349
|
-
def write_target(name: str) -> str:
|
|
372
|
+
def write_target(registry: aliases.Registry, name: str) -> str:
|
|
350
373
|
"""Resolve a model name for a write command. Filtered aliases are refused."""
|
|
351
|
-
alias =
|
|
374
|
+
alias = registry.resolve(name)
|
|
352
375
|
if alias is None:
|
|
353
|
-
problem = _unknown_name(name)
|
|
376
|
+
problem = _unknown_name(registry, name)
|
|
354
377
|
if problem is not None:
|
|
355
378
|
raise problem
|
|
356
379
|
return name
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import stat
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
import typer
|
|
8
9
|
|
|
9
|
-
from odoocli.cli.app import app, emit, fail, session
|
|
10
|
+
from odoocli.cli.app import app, emit, fail, session, warn
|
|
10
11
|
from odoocli.cli.read_cmds import info
|
|
11
|
-
from odoocli.config import load_profiles, remove_profile, save_profile
|
|
12
|
+
from odoocli.config import load_profiles, permissions_enforced, remove_profile, save_profile
|
|
12
13
|
from odoocli.errors import OdooConnectionError
|
|
13
14
|
|
|
14
15
|
profile_app = typer.Typer(
|
|
@@ -111,6 +112,39 @@ def profile_remove(ctx: typer.Context, name: str = typer.Argument(...)) -> None:
|
|
|
111
112
|
|
|
112
113
|
|
|
113
114
|
@profile_app.command("path")
|
|
114
|
-
def profile_path(
|
|
115
|
-
|
|
116
|
-
typer.
|
|
115
|
+
def profile_path(
|
|
116
|
+
ctx: typer.Context,
|
|
117
|
+
check: bool = typer.Option(
|
|
118
|
+
False, "--check", help="Also report whether the file is really owner-only here."
|
|
119
|
+
),
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Print the config file path; --check reports what its permissions are worth."""
|
|
122
|
+
sess = session(ctx)
|
|
123
|
+
if not check:
|
|
124
|
+
typer.echo(str(sess.config))
|
|
125
|
+
return
|
|
126
|
+
path = sess.config
|
|
127
|
+
mode = None
|
|
128
|
+
if path.exists():
|
|
129
|
+
mode = oct(stat.S_IMODE(path.stat().st_mode))
|
|
130
|
+
emit(
|
|
131
|
+
ctx,
|
|
132
|
+
{
|
|
133
|
+
"path": str(path),
|
|
134
|
+
"exists": path.exists(),
|
|
135
|
+
"mode": mode,
|
|
136
|
+
"owner_only_permissions": permissions_enforced(),
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
if not permissions_enforced():
|
|
140
|
+
warn(
|
|
141
|
+
{
|
|
142
|
+
"warning": "permissions_not_enforced",
|
|
143
|
+
"path": str(path),
|
|
144
|
+
"message": (
|
|
145
|
+
"This platform cannot restrict the file to its owner: treat the stored "
|
|
146
|
+
"API key as readable by anyone with access to this machine, or use "
|
|
147
|
+
"--api-key-env instead of --api-key."
|
|
148
|
+
),
|
|
149
|
+
}
|
|
150
|
+
)
|
|
@@ -11,6 +11,7 @@ from odoocli.cli.app import (
|
|
|
11
11
|
check_fields,
|
|
12
12
|
check_model,
|
|
13
13
|
emit,
|
|
14
|
+
fail,
|
|
14
15
|
read_target,
|
|
15
16
|
run,
|
|
16
17
|
session,
|
|
@@ -20,7 +21,7 @@ from odoocli.cli.values import parse_ids, split_fields
|
|
|
20
21
|
from odoocli.client import AsyncOdooClient
|
|
21
22
|
from odoocli.config import Profile
|
|
22
23
|
from odoocli.domain import build_domain
|
|
23
|
-
from odoocli.errors import OdooMissingError, OdooUsageError
|
|
24
|
+
from odoocli.errors import OdooMissingError, OdooRepairedError, OdooUsageError
|
|
24
25
|
from odoocli.lenient import lenient_search_read
|
|
25
26
|
|
|
26
27
|
DEFAULT_LIMIT = 80
|
|
@@ -100,7 +101,7 @@ def fields(
|
|
|
100
101
|
sess = session(ctx)
|
|
101
102
|
|
|
102
103
|
async def go(client: AsyncOdooClient, profile: Profile) -> dict[str, dict[str, Any]]:
|
|
103
|
-
target, _base = read_target(model)
|
|
104
|
+
target, _base = read_target(sess.registry(), model)
|
|
104
105
|
check_model(sess, profile, target)
|
|
105
106
|
return await client.fields_get(target, None if all_attributes else FIELD_ATTRIBUTES)
|
|
106
107
|
|
|
@@ -147,7 +148,9 @@ def search(
|
|
|
147
148
|
False, "--all", help="Fetch every match, paginated. Prefer --format jsonl."
|
|
148
149
|
),
|
|
149
150
|
lenient: bool = typer.Option(
|
|
150
|
-
False,
|
|
151
|
+
False,
|
|
152
|
+
"--lenient-fields",
|
|
153
|
+
help="Drop fields Odoo rejects and retry. Prints the rows, then exits 5 if it had to.",
|
|
151
154
|
),
|
|
152
155
|
ids_only: bool = typer.Option(
|
|
153
156
|
False, "--ids-only", help="Return matching ids only (ORM search instead of search_read)."
|
|
@@ -155,6 +158,12 @@ def search(
|
|
|
155
158
|
) -> None:
|
|
156
159
|
"""search_read on a model. Output is the raw Odoo result."""
|
|
157
160
|
sess = session(ctx)
|
|
161
|
+
repairs: list[dict[str, Any]] = []
|
|
162
|
+
|
|
163
|
+
def note_repair(payload: dict[str, Any]) -> None:
|
|
164
|
+
"""A repair is a warning *and* a fact the exit code has to carry."""
|
|
165
|
+
repairs.append(payload)
|
|
166
|
+
warn(payload)
|
|
158
167
|
|
|
159
168
|
async def fetch(
|
|
160
169
|
client: AsyncOdooClient,
|
|
@@ -166,14 +175,14 @@ def search(
|
|
|
166
175
|
) -> list[dict[str, Any]]:
|
|
167
176
|
if lenient:
|
|
168
177
|
return await lenient_search_read(
|
|
169
|
-
client, target, dom, flds, lim, off, order, on_warning=
|
|
178
|
+
client, target, dom, flds, lim, off, order, on_warning=note_repair
|
|
170
179
|
)
|
|
171
180
|
return await client.search_read(target, dom, flds, lim, off, order)
|
|
172
181
|
|
|
173
182
|
async def go(client: AsyncOdooClient, profile: Profile) -> list[Any]:
|
|
174
|
-
target, base = read_target(model)
|
|
183
|
+
target, base = read_target(sess.registry(), model)
|
|
175
184
|
check_model(sess, profile, target)
|
|
176
|
-
dom = build_domain(domain, where, model=target, base=base)
|
|
185
|
+
dom = build_domain(domain, where, model=target, base=base, registry=sess.registry())
|
|
177
186
|
flds = split_fields(fields_)
|
|
178
187
|
await check_fields(sess, client, profile, target, fields=flds, domain=dom, order=order)
|
|
179
188
|
if ids_only:
|
|
@@ -197,6 +206,16 @@ def search(
|
|
|
197
206
|
off += PAGE_SIZE
|
|
198
207
|
|
|
199
208
|
emit(ctx, run(ctx, go))
|
|
209
|
+
if repairs:
|
|
210
|
+
removed = sorted({str(r.get("field")) for r in repairs})
|
|
211
|
+
fail(
|
|
212
|
+
OdooRepairedError(
|
|
213
|
+
f"--lenient-fields removed {', '.join(removed)} to make the query run: "
|
|
214
|
+
"the rows above answer a wider question than the one you asked.",
|
|
215
|
+
data={"removed_fields": removed, "repairs": repairs},
|
|
216
|
+
),
|
|
217
|
+
sess.verbose,
|
|
218
|
+
)
|
|
200
219
|
|
|
201
220
|
|
|
202
221
|
@app.command()
|
|
@@ -210,9 +229,9 @@ def count(
|
|
|
210
229
|
sess = session(ctx)
|
|
211
230
|
|
|
212
231
|
async def go(client: AsyncOdooClient, profile: Profile) -> int:
|
|
213
|
-
target, base = read_target(model)
|
|
232
|
+
target, base = read_target(sess.registry(), model)
|
|
214
233
|
check_model(sess, profile, target)
|
|
215
|
-
dom = build_domain(domain, where, model=target, base=base)
|
|
234
|
+
dom = build_domain(domain, where, model=target, base=base, registry=sess.registry())
|
|
216
235
|
await check_fields(sess, client, profile, target, domain=dom)
|
|
217
236
|
return await client.search_count(target, dom)
|
|
218
237
|
|
|
@@ -245,9 +264,9 @@ def group(
|
|
|
245
264
|
sess = session(ctx)
|
|
246
265
|
|
|
247
266
|
async def go(client: AsyncOdooClient, profile: Profile) -> list[dict[str, Any]]:
|
|
248
|
-
target, base = read_target(model)
|
|
267
|
+
target, base = read_target(sess.registry(), model)
|
|
249
268
|
check_model(sess, profile, target)
|
|
250
|
-
dom = build_domain(domain, where, model=target, base=base)
|
|
269
|
+
dom = build_domain(domain, where, model=target, base=base, registry=sess.registry())
|
|
251
270
|
groupby = split_fields(by) or []
|
|
252
271
|
if not groupby:
|
|
253
272
|
raise OdooUsageError("--by needs at least one field")
|
|
@@ -288,7 +307,7 @@ def read(
|
|
|
288
307
|
sess = session(ctx)
|
|
289
308
|
|
|
290
309
|
async def go(client: AsyncOdooClient, profile: Profile) -> list[dict[str, Any]]:
|
|
291
|
-
target, _base = read_target(model)
|
|
310
|
+
target, _base = read_target(sess.registry(), model)
|
|
292
311
|
check_model(sess, profile, target)
|
|
293
312
|
id_list = parse_ids(ids)
|
|
294
313
|
flds = split_fields(fields_)
|
|
@@ -75,7 +75,7 @@ def create(
|
|
|
75
75
|
sess = session(ctx)
|
|
76
76
|
|
|
77
77
|
async def go(client: AsyncOdooClient, profile: Profile) -> Any:
|
|
78
|
-
target = write_target(model)
|
|
78
|
+
target = write_target(sess.registry(), model)
|
|
79
79
|
check_model(sess, profile, target)
|
|
80
80
|
vals = _merge_values(values, json_values)
|
|
81
81
|
if not dry_run:
|
|
@@ -104,7 +104,7 @@ def write(
|
|
|
104
104
|
sess = session(ctx)
|
|
105
105
|
|
|
106
106
|
async def go(client: AsyncOdooClient, profile: Profile) -> Any:
|
|
107
|
-
target = write_target(model)
|
|
107
|
+
target = write_target(sess.registry(), model)
|
|
108
108
|
check_model(sess, profile, target)
|
|
109
109
|
id_list = parse_ids(ids)
|
|
110
110
|
vals = _merge_values(values, json_values)
|
|
@@ -132,7 +132,7 @@ def unlink(
|
|
|
132
132
|
sess = session(ctx)
|
|
133
133
|
|
|
134
134
|
async def go(client: AsyncOdooClient, profile: Profile) -> Any:
|
|
135
|
-
target = write_target(model)
|
|
135
|
+
target = write_target(sess.registry(), model)
|
|
136
136
|
check_model(sess, profile, target)
|
|
137
137
|
id_list = parse_ids(ids)
|
|
138
138
|
if dry_run:
|
|
@@ -165,7 +165,7 @@ def call(
|
|
|
165
165
|
sess = session(ctx)
|
|
166
166
|
|
|
167
167
|
async def go(client: AsyncOdooClient, profile: Profile) -> Any:
|
|
168
|
-
target = write_target(model)
|
|
168
|
+
target = write_target(sess.registry(), model)
|
|
169
169
|
check_model(sess, profile, target)
|
|
170
170
|
pos = parse_json_arg(args, "args") or []
|
|
171
171
|
if not isinstance(pos, list):
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import contextlib
|
|
5
6
|
import os
|
|
6
7
|
import tomllib
|
|
7
8
|
from collections.abc import Mapping
|
|
@@ -69,13 +70,41 @@ def _read(path: Path) -> dict[str, Any]:
|
|
|
69
70
|
return tomllib.load(fh)
|
|
70
71
|
|
|
71
72
|
|
|
73
|
+
FILE_MODE = 0o600
|
|
74
|
+
DIR_MODE = 0o700
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def permissions_enforced() -> bool:
|
|
78
|
+
"""Whether ``chmod`` actually keeps other users out on this platform.
|
|
79
|
+
|
|
80
|
+
On POSIX, mode 600 means what it says. On Windows ``os.chmod`` only toggles the
|
|
81
|
+
read-only attribute, so the file is as readable as anything else in the user's
|
|
82
|
+
profile directory. The tool says which of the two it is instead of implying the
|
|
83
|
+
stronger one everywhere.
|
|
84
|
+
"""
|
|
85
|
+
return os.name == "posix"
|
|
86
|
+
|
|
87
|
+
|
|
72
88
|
def _write(path: Path, data: dict[str, Any]) -> None:
|
|
89
|
+
"""Write the profiles atomically, and never leave the key readable in between.
|
|
90
|
+
|
|
91
|
+
The temporary file is *created* with mode 600 rather than created and then
|
|
92
|
+
chmod-ed: with a permissive umask, the second form leaves a window in which the
|
|
93
|
+
API key sits in a world-readable file.
|
|
94
|
+
"""
|
|
73
95
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
-
|
|
96
|
+
with contextlib.suppress(OSError):
|
|
97
|
+
os.chmod(path.parent, DIR_MODE)
|
|
75
98
|
tmp = path.with_suffix(".tmp")
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
99
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, FILE_MODE)
|
|
100
|
+
try:
|
|
101
|
+
with os.fdopen(fd, "wb") as fh:
|
|
102
|
+
tomli_w.dump(data, fh)
|
|
103
|
+
except BaseException:
|
|
104
|
+
tmp.unlink(missing_ok=True)
|
|
105
|
+
raise
|
|
106
|
+
with contextlib.suppress(OSError):
|
|
107
|
+
os.chmod(tmp, FILE_MODE)
|
|
79
108
|
os.replace(tmp, path)
|
|
80
109
|
|
|
81
110
|
|
|
@@ -16,7 +16,8 @@ _UNARY_OP = "!"
|
|
|
16
16
|
# Trailing "(#42)" of a humanised many2one ("Name (#42)"), as produced by
|
|
17
17
|
# LLM-facing layers. Recovered back to the int id when fed into a domain.
|
|
18
18
|
_HUMANIZED_M2O_RE = re.compile(r"\(#(\d+)\)\s*$")
|
|
19
|
-
|
|
19
|
+
_TRUE = object()
|
|
20
|
+
"""A sub-expression that matches everything, once the field it filtered on is removed."""
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
# ----- normalisation (ported from UpBoard's OdooConnector) -----
|
|
@@ -103,30 +104,59 @@ def _parse(tokens: list[Any], pos: int) -> tuple[Any, int]:
|
|
|
103
104
|
return ("leaf", tok), pos + 1
|
|
104
105
|
|
|
105
106
|
|
|
107
|
+
def _mentions(node: Any, bad_field: str) -> bool:
|
|
108
|
+
kind = node[0]
|
|
109
|
+
if kind == "leaf":
|
|
110
|
+
term = node[1]
|
|
111
|
+
return bool(_is_leaf(term) and term[0] == bad_field)
|
|
112
|
+
if kind == "not":
|
|
113
|
+
return _mentions(node[1], bad_field)
|
|
114
|
+
return _mentions(node[2], bad_field) or _mentions(node[3], bad_field)
|
|
115
|
+
|
|
116
|
+
|
|
106
117
|
def _serialize(node: Any, bad_field: str) -> Any:
|
|
118
|
+
"""Replace every leaf on ``bad_field`` with "matches everything", then simplify.
|
|
119
|
+
|
|
120
|
+
Repairing a query may only ever widen it. Two places make that non-obvious:
|
|
121
|
+
|
|
122
|
+
* Under ``!``, weakening the operand *strengthens* the result, so the whole negation
|
|
123
|
+
has to go rather than the leaf inside it.
|
|
124
|
+
* Under ``|``, an operand that matches everything absorbs the disjunction. Keeping
|
|
125
|
+
only the other operand would drop records the caller explicitly asked for.
|
|
126
|
+
"""
|
|
107
127
|
kind = node[0]
|
|
108
128
|
if kind == "leaf":
|
|
109
129
|
term = node[1]
|
|
110
130
|
if _is_leaf(term) and term[0] == bad_field:
|
|
111
|
-
return
|
|
131
|
+
return _TRUE
|
|
112
132
|
return [term]
|
|
113
133
|
if kind == "not":
|
|
114
|
-
|
|
115
|
-
|
|
134
|
+
if _mentions(node[1], bad_field):
|
|
135
|
+
return _TRUE
|
|
136
|
+
return [_UNARY_OP, *_serialize(node[1], bad_field)]
|
|
116
137
|
_, operator, left_node, right_node = node
|
|
117
138
|
left = _serialize(left_node, bad_field)
|
|
118
139
|
right = _serialize(right_node, bad_field)
|
|
119
|
-
if
|
|
120
|
-
|
|
121
|
-
|
|
140
|
+
if operator == "|":
|
|
141
|
+
if left is _TRUE or right is _TRUE:
|
|
142
|
+
return _TRUE
|
|
143
|
+
return [operator, *left, *right]
|
|
144
|
+
if left is _TRUE and right is _TRUE:
|
|
145
|
+
return _TRUE
|
|
146
|
+
if left is _TRUE:
|
|
122
147
|
return right
|
|
123
|
-
if right is
|
|
148
|
+
if right is _TRUE:
|
|
124
149
|
return left
|
|
125
150
|
return [operator, *left, *right]
|
|
126
151
|
|
|
127
152
|
|
|
128
153
|
def strip_field_from_domain(domain: Any, bad_field: str) -> Any:
|
|
129
|
-
"""Remove every
|
|
154
|
+
"""Remove every constraint on ``bad_field``, widening the domain and never narrowing it.
|
|
155
|
+
|
|
156
|
+
Every record the original domain matched still matches the result. That is the only
|
|
157
|
+
property that makes an automatic repair defensible: returning extra rows is visible,
|
|
158
|
+
losing rows the caller asked for is not.
|
|
159
|
+
"""
|
|
130
160
|
if not isinstance(domain, list) or not domain:
|
|
131
161
|
return domain
|
|
132
162
|
try:
|
|
@@ -135,7 +165,7 @@ def strip_field_from_domain(domain: Any, bad_field: str) -> Any:
|
|
|
135
165
|
while pos < len(domain):
|
|
136
166
|
node, pos = _parse(domain, pos)
|
|
137
167
|
serialized = _serialize(node, bad_field)
|
|
138
|
-
if serialized is not
|
|
168
|
+
if serialized is not _TRUE:
|
|
139
169
|
cleaned.extend(serialized)
|
|
140
170
|
return cleaned
|
|
141
171
|
except (IndexError, TypeError):
|
|
@@ -218,10 +248,12 @@ def parse_where(expr: str) -> list[Any]:
|
|
|
218
248
|
_BARE_WORD_RE = re.compile(r"^[A-Za-z][\w-]*$")
|
|
219
249
|
|
|
220
250
|
|
|
221
|
-
def _parse_condition(
|
|
251
|
+
def _parse_condition(
|
|
252
|
+
model: str | None, token: str, today: date | None, registry: aliases.Registry
|
|
253
|
+
) -> list[list[Any]]:
|
|
222
254
|
"""One ``-w`` token: a preset name if it is one, otherwise a domain leaf."""
|
|
223
255
|
if model is not None:
|
|
224
|
-
expanded =
|
|
256
|
+
expanded = registry.expand(model, token, today)
|
|
225
257
|
if expanded is not None:
|
|
226
258
|
return expanded
|
|
227
259
|
try:
|
|
@@ -229,7 +261,7 @@ def _parse_condition(model: str | None, token: str, today: date | None) -> list[
|
|
|
229
261
|
except OdooUsageError:
|
|
230
262
|
if not _BARE_WORD_RE.match(token.strip()):
|
|
231
263
|
raise
|
|
232
|
-
known = sorted(
|
|
264
|
+
known = sorted(registry.presets_for(model))
|
|
233
265
|
raise OdooUsageError(
|
|
234
266
|
f"{token!r} is neither a condition nor a preset"
|
|
235
267
|
+ (f" for {model}" if model else "")
|
|
@@ -245,15 +277,17 @@ def build_domain(
|
|
|
245
277
|
model: str | None = None,
|
|
246
278
|
base: list[list[Any]] | None = None,
|
|
247
279
|
today: date | None = None,
|
|
280
|
+
registry: aliases.Registry | None = None,
|
|
248
281
|
) -> list[Any]:
|
|
249
282
|
"""AND the alias clauses, an optional JSON domain and every ``-w`` condition.
|
|
250
283
|
|
|
251
284
|
``model`` enables preset names in ``-w``; without it every token must be a
|
|
252
285
|
plain ``field op value`` condition.
|
|
253
286
|
"""
|
|
287
|
+
table = registry or aliases.BUILTIN
|
|
254
288
|
out: list[Any] = list(base or [])
|
|
255
289
|
if domain:
|
|
256
290
|
out += sanitize_domain(normalize_domain(domain))
|
|
257
291
|
for token in where:
|
|
258
|
-
out += _parse_condition(model, token, today)
|
|
292
|
+
out += _parse_condition(model, token, today, table)
|
|
259
293
|
return out
|
|
@@ -79,6 +79,17 @@ class OdooUsageError(OdooError):
|
|
|
79
79
|
default_code = "usage_error"
|
|
80
80
|
|
|
81
81
|
|
|
82
|
+
class OdooRepairedError(OdooError):
|
|
83
|
+
"""The query was repaired to make it run, so the result answers a wider question.
|
|
84
|
+
|
|
85
|
+
Raised after the data has been printed: the rows are real, they are simply not the
|
|
86
|
+
rows that were asked for. A human exploring can ignore it; a script cannot.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
exit_code = 5
|
|
90
|
+
default_code = "result_repaired"
|
|
91
|
+
|
|
92
|
+
|
|
82
93
|
_BY_EXCEPTION_NAME: dict[str, type[OdooError]] = {
|
|
83
94
|
"odoo.exceptions.AccessDenied": OdooAuthError,
|
|
84
95
|
"odoo.exceptions.AccessError": OdooAccessError,
|
|
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
|