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.
Files changed (30) hide show
  1. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/PKG-INFO +38 -6
  2. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/README.md +37 -5
  3. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/SKILL.md +7 -3
  4. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/pyproject.toml +2 -1
  5. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/AGENT_GUIDE.md +7 -3
  6. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/__init__.py +2 -0
  7. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/_version.py +1 -1
  8. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/aliases.py +112 -1
  9. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/alias_cmd.py +5 -5
  10. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/app.py +31 -8
  11. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/profile_cmds.py +39 -5
  12. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/read_cmds.py +30 -11
  13. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/write_cmds.py +4 -4
  14. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/config.py +33 -4
  15. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/domain.py +48 -14
  16. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/errors.py +11 -0
  17. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/.gitignore +0 -0
  18. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/LICENSE +0 -0
  19. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/__main__.py +0 -0
  20. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/__init__.py +0 -0
  21. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/cache_cmds.py +0 -0
  22. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/guide_cmd.py +0 -0
  23. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/output.py +0 -0
  24. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/values.py +0 -0
  25. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/client.py +0 -0
  26. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/lenient.py +0 -0
  27. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/py.typed +0 -0
  28. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/schema.py +0 -0
  29. {odoo_agent_cli-0.4.0 → odoo_agent_cli-0.5.0}/src/odoocli/security.py +0 -0
  30. {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.4.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 `0600` TOML file:
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, with a warning on
196
- stderr. Exploration only.
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. Read it before changing anything.
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 `0600` TOML file:
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, with a warning on
169
- stderr. Exploration only.
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. Read it before changing anything.
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, with a warning on
177
- stderr; only use it for exploration, never in a script that relies on the result.
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.4.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, with a warning on
172
- stderr; only use it for exploration, never in a script that relies on the result.
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__",
@@ -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.5.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"]]
@@ -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 = aliases.suggest_alias(name)
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 = aliases.resolve(name)
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 = aliases.resolve(name)
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(ctx: typer.Context) -> None:
115
- """Print the config file path."""
116
- typer.echo(str(session(ctx).config))
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, "--lenient-fields", help="Drop fields Odoo rejects and retry (warns on stderr)."
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=warn
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
- os.chmod(path.parent, 0o700)
96
+ with contextlib.suppress(OSError):
97
+ os.chmod(path.parent, DIR_MODE)
75
98
  tmp = path.with_suffix(".tmp")
76
- with tmp.open("wb") as fh:
77
- tomli_w.dump(data, fh)
78
- os.chmod(tmp, 0o600)
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
- _DROP = object()
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 _DROP
131
+ return _TRUE
112
132
  return [term]
113
133
  if kind == "not":
114
- child = _serialize(node[1], bad_field)
115
- return _DROP if child is _DROP else [_UNARY_OP, *child]
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 left is _DROP and right is _DROP:
120
- return _DROP
121
- if left is _DROP:
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 _DROP:
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 leaf on ``bad_field``; ``|``/``&`` fold onto the surviving operand."""
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 _DROP:
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(model: str | None, token: str, today: date | None) -> list[list[Any]]:
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 = aliases.expand(model, token, today)
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(aliases.presets_for(model))
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