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.
@@ -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))