odoo-agent-cli 0.2.0__py3-none-any.whl
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.2.0.dist-info/METADATA +208 -0
- odoo_agent_cli-0.2.0.dist-info/RECORD +25 -0
- odoo_agent_cli-0.2.0.dist-info/WHEEL +4 -0
- odoo_agent_cli-0.2.0.dist-info/entry_points.txt +2 -0
- odoo_agent_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- odoocli/AGENT_GUIDE.md +139 -0
- odoocli/__init__.py +29 -0
- odoocli/__main__.py +5 -0
- odoocli/_version.py +3 -0
- odoocli/cli/__init__.py +0 -0
- odoocli/cli/app.py +337 -0
- odoocli/cli/guide_cmd.py +19 -0
- odoocli/cli/output.py +105 -0
- odoocli/cli/profile_cmds.py +116 -0
- odoocli/cli/read_cmds.py +223 -0
- odoocli/cli/values.py +51 -0
- odoocli/cli/write_cmds.py +178 -0
- odoocli/client.py +312 -0
- odoocli/config.py +175 -0
- odoocli/domain.py +208 -0
- odoocli/errors.py +99 -0
- odoocli/lenient.py +93 -0
- odoocli/py.typed +0 -0
- odoocli/security.py +83 -0
- odoocli/sync.py +126 -0
odoocli/cli/read_cmds.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Read-only commands: info, models, fields, search, count, read."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from odoocli.cli.app import app, check_model, emit, run, session, warn
|
|
10
|
+
from odoocli.cli.values import parse_ids, split_fields
|
|
11
|
+
from odoocli.client import AsyncOdooClient
|
|
12
|
+
from odoocli.config import Profile
|
|
13
|
+
from odoocli.domain import build_domain
|
|
14
|
+
from odoocli.errors import OdooMissingError
|
|
15
|
+
from odoocli.lenient import lenient_search_read
|
|
16
|
+
|
|
17
|
+
DEFAULT_LIMIT = 80
|
|
18
|
+
PAGE_SIZE = 200
|
|
19
|
+
FIELD_ATTRIBUTES = ["string", "type", "required", "readonly", "store", "relation", "selection"]
|
|
20
|
+
|
|
21
|
+
WhereOpt = typer.Option(
|
|
22
|
+
[], "--where", "-w", help="Condition 'field op value'. Repeatable, AND-ed together."
|
|
23
|
+
)
|
|
24
|
+
DomainOpt = typer.Option(None, "--domain", "-d", help="Odoo domain as JSON, AND-ed with -w.")
|
|
25
|
+
FieldsOpt = typer.Option(None, "--fields", help="Comma-separated field names.")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command()
|
|
29
|
+
def info(
|
|
30
|
+
ctx: typer.Context,
|
|
31
|
+
modules: bool = typer.Option(False, "--modules", help="Also list installed module names."),
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Server version, authenticated uid and connection source. Use it to test a connection."""
|
|
34
|
+
|
|
35
|
+
async def go(client: AsyncOdooClient, profile: Profile) -> dict[str, Any]:
|
|
36
|
+
version = await client.version()
|
|
37
|
+
uid = await client.authenticate()
|
|
38
|
+
out: dict[str, Any] = {
|
|
39
|
+
"url": profile.url,
|
|
40
|
+
"database": profile.database,
|
|
41
|
+
"login": profile.login,
|
|
42
|
+
"uid": uid,
|
|
43
|
+
"source": profile.source,
|
|
44
|
+
"allow_writes": profile.allow_writes,
|
|
45
|
+
"server_version": version.get("server_version"),
|
|
46
|
+
"server_version_info": version.get("server_version_info"),
|
|
47
|
+
}
|
|
48
|
+
if modules:
|
|
49
|
+
rows = await client.search_read(
|
|
50
|
+
"ir.module.module", [["state", "=", "installed"]], ["name"], limit=2000
|
|
51
|
+
)
|
|
52
|
+
out["modules"] = sorted(r["name"] for r in rows if r.get("name"))
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
emit(ctx, run(ctx, go))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command()
|
|
59
|
+
def models(
|
|
60
|
+
ctx: typer.Context,
|
|
61
|
+
like: str | None = typer.Option(None, "--like", help="Substring of the technical name."),
|
|
62
|
+
where: list[str] = WhereOpt,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""List models (technical name, label, transient)."""
|
|
65
|
+
|
|
66
|
+
async def go(client: AsyncOdooClient, _profile: Profile) -> list[dict[str, Any]]:
|
|
67
|
+
domain = build_domain(None, where)
|
|
68
|
+
if like:
|
|
69
|
+
domain.append(["model", "ilike", like])
|
|
70
|
+
return await client.search_read(
|
|
71
|
+
"ir.model", domain, ["model", "name", "transient"], order="model"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
emit(ctx, run(ctx, go))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command()
|
|
78
|
+
def fields(
|
|
79
|
+
ctx: typer.Context,
|
|
80
|
+
model: str = typer.Argument(..., help="Technical model name, e.g. res.partner"),
|
|
81
|
+
type_: str | None = typer.Option(None, "--type", help="Keep only this field type."),
|
|
82
|
+
stored: bool = typer.Option(
|
|
83
|
+
False, "--stored", help="Keep only stored fields (searchable and orderable)."
|
|
84
|
+
),
|
|
85
|
+
search: str | None = typer.Option(None, "--search", help="Substring of the name or label."),
|
|
86
|
+
all_attributes: bool = typer.Option(
|
|
87
|
+
False, "--all-attributes", help="Return every attribute Odoo has, not the curated set."
|
|
88
|
+
),
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Introspect a model: field types, required, store, relation, selection values."""
|
|
91
|
+
sess = session(ctx)
|
|
92
|
+
|
|
93
|
+
async def go(client: AsyncOdooClient, profile: Profile) -> dict[str, dict[str, Any]]:
|
|
94
|
+
check_model(sess, profile, model)
|
|
95
|
+
return await client.fields_get(model, None if all_attributes else FIELD_ATTRIBUTES)
|
|
96
|
+
|
|
97
|
+
data = run(ctx, go)
|
|
98
|
+
needle = search.lower() if search else None
|
|
99
|
+
out = {
|
|
100
|
+
name: info
|
|
101
|
+
for name, info in data.items()
|
|
102
|
+
if (type_ is None or info.get("type") == type_)
|
|
103
|
+
and (not stored or bool(info.get("store")))
|
|
104
|
+
and (
|
|
105
|
+
needle is None
|
|
106
|
+
or needle in name.lower()
|
|
107
|
+
or needle in str(info.get("string", "")).lower()
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
rows = [
|
|
111
|
+
{
|
|
112
|
+
"field": name,
|
|
113
|
+
"type": info.get("type"),
|
|
114
|
+
"label": info.get("string"),
|
|
115
|
+
"required": info.get("required", False),
|
|
116
|
+
"store": info.get("store", False),
|
|
117
|
+
"relation": info.get("relation", ""),
|
|
118
|
+
}
|
|
119
|
+
for name, info in out.items()
|
|
120
|
+
]
|
|
121
|
+
emit(ctx, out, table_rows=rows)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@app.command()
|
|
125
|
+
def search(
|
|
126
|
+
ctx: typer.Context,
|
|
127
|
+
model: str = typer.Argument(..., help="Technical model name"),
|
|
128
|
+
where: list[str] = WhereOpt,
|
|
129
|
+
domain: str | None = DomainOpt,
|
|
130
|
+
fields_: str | None = FieldsOpt,
|
|
131
|
+
limit: int | None = typer.Option(
|
|
132
|
+
None, "--limit", "-l", help=f"Max records (default {DEFAULT_LIMIT})."
|
|
133
|
+
),
|
|
134
|
+
offset: int = typer.Option(0, "--offset"),
|
|
135
|
+
order: str | None = typer.Option(None, "--order", help='e.g. "date_order desc, id"'),
|
|
136
|
+
all_: bool = typer.Option(
|
|
137
|
+
False, "--all", help="Fetch every match, paginated. Prefer --format jsonl."
|
|
138
|
+
),
|
|
139
|
+
lenient: bool = typer.Option(
|
|
140
|
+
False, "--lenient-fields", help="Drop fields Odoo rejects and retry (warns on stderr)."
|
|
141
|
+
),
|
|
142
|
+
ids_only: bool = typer.Option(
|
|
143
|
+
False, "--ids-only", help="Return matching ids only (ORM search instead of search_read)."
|
|
144
|
+
),
|
|
145
|
+
) -> None:
|
|
146
|
+
"""search_read on a model. Output is the raw Odoo result."""
|
|
147
|
+
sess = session(ctx)
|
|
148
|
+
|
|
149
|
+
async def fetch(
|
|
150
|
+
client: AsyncOdooClient, dom: list[Any], flds: list[str] | None, lim: int | None, off: int
|
|
151
|
+
) -> list[dict[str, Any]]:
|
|
152
|
+
if lenient:
|
|
153
|
+
return await lenient_search_read(
|
|
154
|
+
client, model, dom, flds, lim, off, order, on_warning=warn
|
|
155
|
+
)
|
|
156
|
+
return await client.search_read(model, dom, flds, lim, off, order)
|
|
157
|
+
|
|
158
|
+
async def go(client: AsyncOdooClient, profile: Profile) -> list[Any]:
|
|
159
|
+
check_model(sess, profile, model)
|
|
160
|
+
dom = build_domain(domain, where)
|
|
161
|
+
flds = split_fields(fields_)
|
|
162
|
+
if ids_only:
|
|
163
|
+
return await client.search(
|
|
164
|
+
model,
|
|
165
|
+
dom,
|
|
166
|
+
None if all_ else (DEFAULT_LIMIT if limit is None else limit),
|
|
167
|
+
offset,
|
|
168
|
+
order,
|
|
169
|
+
)
|
|
170
|
+
if not all_:
|
|
171
|
+
return await fetch(client, dom, flds, DEFAULT_LIMIT if limit is None else limit, offset)
|
|
172
|
+
rows: list[dict[str, Any]] = []
|
|
173
|
+
off = offset
|
|
174
|
+
while True:
|
|
175
|
+
page = await fetch(client, dom, flds, PAGE_SIZE, off)
|
|
176
|
+
rows.extend(page)
|
|
177
|
+
if len(page) < PAGE_SIZE:
|
|
178
|
+
return rows
|
|
179
|
+
off += PAGE_SIZE
|
|
180
|
+
|
|
181
|
+
emit(ctx, run(ctx, go))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@app.command()
|
|
185
|
+
def count(
|
|
186
|
+
ctx: typer.Context,
|
|
187
|
+
model: str = typer.Argument(...),
|
|
188
|
+
where: list[str] = WhereOpt,
|
|
189
|
+
domain: str | None = DomainOpt,
|
|
190
|
+
) -> None:
|
|
191
|
+
"""search_count on a model. Prints a bare integer."""
|
|
192
|
+
sess = session(ctx)
|
|
193
|
+
|
|
194
|
+
async def go(client: AsyncOdooClient, profile: Profile) -> int:
|
|
195
|
+
check_model(sess, profile, model)
|
|
196
|
+
return await client.search_count(model, build_domain(domain, where))
|
|
197
|
+
|
|
198
|
+
emit(ctx, run(ctx, go))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@app.command()
|
|
202
|
+
def read(
|
|
203
|
+
ctx: typer.Context,
|
|
204
|
+
model: str = typer.Argument(...),
|
|
205
|
+
ids: list[str] = typer.Argument(..., help="Record ids, space or comma separated."),
|
|
206
|
+
fields_: str | None = FieldsOpt,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Read records by id. Exit 1 (missing_record) if any id is missing or not visible."""
|
|
209
|
+
sess = session(ctx)
|
|
210
|
+
|
|
211
|
+
async def go(client: AsyncOdooClient, profile: Profile) -> list[dict[str, Any]]:
|
|
212
|
+
check_model(sess, profile, model)
|
|
213
|
+
id_list = parse_ids(ids)
|
|
214
|
+
rows = await client.read(model, id_list, split_fields(fields_))
|
|
215
|
+
missing = [i for i in id_list if i not in {r.get("id") for r in rows}]
|
|
216
|
+
if missing:
|
|
217
|
+
raise OdooMissingError(
|
|
218
|
+
f"{model} records not found or not visible: {missing}",
|
|
219
|
+
data={"missing_ids": missing, "records": rows},
|
|
220
|
+
)
|
|
221
|
+
return rows
|
|
222
|
+
|
|
223
|
+
emit(ctx, run(ctx, go))
|
odoocli/cli/values.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Parsing of -v key=value pairs, id lists and JSON arguments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from odoocli.domain import parse_value
|
|
9
|
+
from odoocli.errors import OdooUsageError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_ids(tokens: list[str]) -> list[int]:
|
|
13
|
+
ids: list[int] = []
|
|
14
|
+
for tok in tokens:
|
|
15
|
+
for part in tok.split(","):
|
|
16
|
+
part = part.strip()
|
|
17
|
+
if not part:
|
|
18
|
+
continue
|
|
19
|
+
try:
|
|
20
|
+
ids.append(int(part))
|
|
21
|
+
except ValueError as e:
|
|
22
|
+
raise OdooUsageError(f"Record id must be an integer, got {part!r}") from e
|
|
23
|
+
if not ids:
|
|
24
|
+
raise OdooUsageError("At least one record id is required")
|
|
25
|
+
return ids
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_kv(pairs: list[str]) -> dict[str, Any]:
|
|
29
|
+
out: dict[str, Any] = {}
|
|
30
|
+
for pair in pairs:
|
|
31
|
+
key, sep, raw = pair.partition("=")
|
|
32
|
+
if not sep or not key.strip():
|
|
33
|
+
raise OdooUsageError(f"Expected key=value, got {pair!r}")
|
|
34
|
+
out[key.strip()] = parse_value(raw)
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_json_arg(text: str | None, kind: str) -> Any:
|
|
39
|
+
if text is None or not text.strip():
|
|
40
|
+
return None
|
|
41
|
+
try:
|
|
42
|
+
return json.loads(text)
|
|
43
|
+
except ValueError as e:
|
|
44
|
+
raise OdooUsageError(f"--{kind} must be valid JSON: {e}") from e
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def split_fields(text: str | None) -> list[str] | None:
|
|
48
|
+
if text is None:
|
|
49
|
+
return None
|
|
50
|
+
fields = [f.strip() for f in text.split(",") if f.strip()]
|
|
51
|
+
return fields or None
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Write commands, all behind allow_writes; unlink and non read-safe call also need --yes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from odoocli.cli.app import (
|
|
10
|
+
app,
|
|
11
|
+
check_model,
|
|
12
|
+
emit,
|
|
13
|
+
require_writes,
|
|
14
|
+
require_yes,
|
|
15
|
+
run,
|
|
16
|
+
session,
|
|
17
|
+
warn,
|
|
18
|
+
)
|
|
19
|
+
from odoocli.cli.values import parse_ids, parse_json_arg, parse_kv
|
|
20
|
+
from odoocli.client import AsyncOdooClient
|
|
21
|
+
from odoocli.config import Profile
|
|
22
|
+
from odoocli.errors import OdooUsageError
|
|
23
|
+
from odoocli.security import is_read_safe_method
|
|
24
|
+
|
|
25
|
+
ValuesOpt = typer.Option(
|
|
26
|
+
[], "--value", "-v", help="field=value. Repeatable. Overrides keys from --values."
|
|
27
|
+
)
|
|
28
|
+
JsonValuesOpt = typer.Option(None, "--values", help="Values as a JSON object.")
|
|
29
|
+
DryRunOpt = typer.Option(False, "--dry-run", help="Print the payload, call nothing, exit 0.")
|
|
30
|
+
YesOpt = typer.Option(False, "--yes", "-y", help="Confirm a destructive or arbitrary call.")
|
|
31
|
+
|
|
32
|
+
# Sentinel returned by a dry run so the command prints nothing more.
|
|
33
|
+
_DRY = object()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _merge_values(pairs: list[str], json_values: str | None) -> dict[str, Any]:
|
|
37
|
+
base = parse_json_arg(json_values, "values") or {}
|
|
38
|
+
if not isinstance(base, dict):
|
|
39
|
+
raise OdooUsageError("--values must be a JSON object")
|
|
40
|
+
base.update(parse_kv(pairs))
|
|
41
|
+
if not base:
|
|
42
|
+
raise OdooUsageError("No values given. Use -v field=value or --values '{...}'.")
|
|
43
|
+
return base
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _dry_run(
|
|
47
|
+
ctx: typer.Context, model: str, method: str, args: list[Any], kwargs: dict[str, Any]
|
|
48
|
+
) -> object:
|
|
49
|
+
emit(ctx, {"dry_run": True, "model": model, "method": method, "args": args, "kwargs": kwargs})
|
|
50
|
+
return _DRY
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _log_write(model: str, method: str, ids: list[int], fields: list[str]) -> None:
|
|
54
|
+
warn({"write": {"model": model, "method": method, "ids": ids, "fields": fields}})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _emit_unless_dry(ctx: typer.Context, result: Any) -> None:
|
|
58
|
+
if result is not _DRY:
|
|
59
|
+
emit(ctx, result)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command()
|
|
63
|
+
def create(
|
|
64
|
+
ctx: typer.Context,
|
|
65
|
+
model: str = typer.Argument(...),
|
|
66
|
+
values: list[str] = ValuesOpt,
|
|
67
|
+
json_values: str | None = JsonValuesOpt,
|
|
68
|
+
dry_run: bool = DryRunOpt,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Create one record. Prints the new id."""
|
|
71
|
+
sess = session(ctx)
|
|
72
|
+
|
|
73
|
+
async def go(client: AsyncOdooClient, profile: Profile) -> Any:
|
|
74
|
+
check_model(sess, profile, model)
|
|
75
|
+
vals = _merge_values(values, json_values)
|
|
76
|
+
if dry_run:
|
|
77
|
+
return _dry_run(ctx, model, "create", [vals], {})
|
|
78
|
+
require_writes(profile)
|
|
79
|
+
new_id = await client.create(model, vals)
|
|
80
|
+
ids = [new_id] if isinstance(new_id, int) else list(new_id)
|
|
81
|
+
_log_write(model, "create", ids, sorted(vals))
|
|
82
|
+
return new_id
|
|
83
|
+
|
|
84
|
+
_emit_unless_dry(ctx, run(ctx, go))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.command()
|
|
88
|
+
def write(
|
|
89
|
+
ctx: typer.Context,
|
|
90
|
+
model: str = typer.Argument(...),
|
|
91
|
+
ids: list[str] = typer.Argument(..., help="Record ids, space or comma separated."),
|
|
92
|
+
values: list[str] = ValuesOpt,
|
|
93
|
+
json_values: str | None = JsonValuesOpt,
|
|
94
|
+
dry_run: bool = DryRunOpt,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Update records. Prints true."""
|
|
97
|
+
sess = session(ctx)
|
|
98
|
+
|
|
99
|
+
async def go(client: AsyncOdooClient, profile: Profile) -> Any:
|
|
100
|
+
check_model(sess, profile, model)
|
|
101
|
+
id_list = parse_ids(ids)
|
|
102
|
+
vals = _merge_values(values, json_values)
|
|
103
|
+
if dry_run:
|
|
104
|
+
return _dry_run(ctx, model, "write", [id_list, vals], {})
|
|
105
|
+
require_writes(profile)
|
|
106
|
+
ok = await client.write(model, id_list, vals)
|
|
107
|
+
_log_write(model, "write", id_list, sorted(vals))
|
|
108
|
+
return ok
|
|
109
|
+
|
|
110
|
+
_emit_unless_dry(ctx, run(ctx, go))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.command()
|
|
114
|
+
def unlink(
|
|
115
|
+
ctx: typer.Context,
|
|
116
|
+
model: str = typer.Argument(...),
|
|
117
|
+
ids: list[str] = typer.Argument(..., help="Record ids, space or comma separated."),
|
|
118
|
+
yes: bool = YesOpt,
|
|
119
|
+
dry_run: bool = DryRunOpt,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Delete records. Needs allow_writes and --yes."""
|
|
122
|
+
sess = session(ctx)
|
|
123
|
+
|
|
124
|
+
async def go(client: AsyncOdooClient, profile: Profile) -> Any:
|
|
125
|
+
check_model(sess, profile, model)
|
|
126
|
+
id_list = parse_ids(ids)
|
|
127
|
+
if dry_run:
|
|
128
|
+
return _dry_run(ctx, model, "unlink", [id_list], {})
|
|
129
|
+
require_writes(profile)
|
|
130
|
+
require_yes(sess, yes, f"Deleting {len(id_list)} {model} record(s)")
|
|
131
|
+
ok = await client.unlink(model, id_list)
|
|
132
|
+
_log_write(model, "unlink", id_list, [])
|
|
133
|
+
return ok
|
|
134
|
+
|
|
135
|
+
_emit_unless_dry(ctx, run(ctx, go))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.command()
|
|
139
|
+
def call(
|
|
140
|
+
ctx: typer.Context,
|
|
141
|
+
model: str = typer.Argument(...),
|
|
142
|
+
method: str = typer.Argument(
|
|
143
|
+
..., help="Any model method, e.g. action_confirm, name_search, read_group"
|
|
144
|
+
),
|
|
145
|
+
ids: str | None = typer.Option(
|
|
146
|
+
None, "--ids", help="Record ids (comma separated), prepended as the first positional arg."
|
|
147
|
+
),
|
|
148
|
+
args: str | None = typer.Option(None, "--args", help="Positional args as a JSON list."),
|
|
149
|
+
kwargs: str | None = typer.Option(None, "--kwargs", help="Keyword args as a JSON object."),
|
|
150
|
+
yes: bool = YesOpt,
|
|
151
|
+
dry_run: bool = DryRunOpt,
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Call a model method through execute_kw. Read-only methods need no guard."""
|
|
154
|
+
sess = session(ctx)
|
|
155
|
+
|
|
156
|
+
async def go(client: AsyncOdooClient, profile: Profile) -> Any:
|
|
157
|
+
check_model(sess, profile, model)
|
|
158
|
+
pos = parse_json_arg(args, "args") or []
|
|
159
|
+
if not isinstance(pos, list):
|
|
160
|
+
raise OdooUsageError("--args must be a JSON list")
|
|
161
|
+
kw = parse_json_arg(kwargs, "kwargs") or {}
|
|
162
|
+
if not isinstance(kw, dict):
|
|
163
|
+
raise OdooUsageError("--kwargs must be a JSON object")
|
|
164
|
+
id_list = parse_ids([ids]) if ids else []
|
|
165
|
+
if id_list:
|
|
166
|
+
pos = [id_list, *pos]
|
|
167
|
+
if dry_run:
|
|
168
|
+
return _dry_run(ctx, model, method, pos, kw)
|
|
169
|
+
mutating = not is_read_safe_method(method)
|
|
170
|
+
if mutating:
|
|
171
|
+
require_writes(profile)
|
|
172
|
+
require_yes(sess, yes, f"Calling {model}.{method}")
|
|
173
|
+
result = await client.execute(model, method, *pos, **kw)
|
|
174
|
+
if mutating:
|
|
175
|
+
_log_write(model, method, id_list, [])
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
_emit_unless_dry(ctx, run(ctx, go))
|