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.
odoocli/cli/app.py ADDED
@@ -0,0 +1,337 @@
1
+ """Typer application: global options, session, run/emit helpers, exit code mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import json
8
+ import logging
9
+ import os
10
+ import sys
11
+ from collections.abc import Awaitable, Callable, Iterator, Mapping
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any, TypeVar
15
+
16
+ import typer
17
+ from typer.core import TyperGroup
18
+
19
+ from odoocli._version import __version__
20
+ from odoocli.cli.output import FORMATS, detect_format, render
21
+ from odoocli.client import AsyncOdooClient
22
+ from odoocli.config import ENV_ASSUME_YES, Profile, config_path, env_flag, resolve_profile
23
+ from odoocli.errors import OdooError, OdooRefusedError
24
+ from odoocli.security import is_sensitive_model, redact
25
+
26
+ T = TypeVar("T")
27
+
28
+ # Root options that may appear anywhere on the command line. Agents naturally
29
+ # write ``odoo search res.partner --format jsonl``; click only accepts group
30
+ # options before the subcommand, so we hoist them.
31
+ _GLOBAL_WITH_VALUE = {
32
+ "--profile",
33
+ "-p",
34
+ "--format",
35
+ "-f",
36
+ "--timeout",
37
+ "--context",
38
+ "--company",
39
+ "--lang",
40
+ }
41
+ _GLOBAL_FLAGS = {
42
+ "--no-redact",
43
+ "--include-sensitive",
44
+ "--include-archived",
45
+ "--insecure",
46
+ "--verbose",
47
+ "--debug",
48
+ "--version",
49
+ }
50
+
51
+
52
+ def hoist_global_options(args: list[str]) -> list[str]:
53
+ """Move root options found after the subcommand to the front, keeping order."""
54
+ hoisted: list[str] = []
55
+ rest: list[str] = []
56
+ i = 0
57
+ while i < len(args):
58
+ tok = args[i]
59
+ if tok == "--":
60
+ rest.extend(args[i:])
61
+ break
62
+ name, eq, _value = tok.partition("=")
63
+ if tok in _GLOBAL_FLAGS:
64
+ hoisted.append(tok)
65
+ elif tok in _GLOBAL_WITH_VALUE and i + 1 < len(args):
66
+ hoisted.extend(args[i : i + 2])
67
+ i += 1
68
+ elif eq and name in _GLOBAL_WITH_VALUE:
69
+ hoisted.append(tok)
70
+ else:
71
+ rest.append(tok)
72
+ i += 1
73
+ return hoisted + rest
74
+
75
+
76
+ class _RootGroup(TyperGroup):
77
+ def parse_args(self, ctx: Any, args: list[str]) -> list[str]:
78
+ return super().parse_args(ctx, hoist_global_options(args))
79
+
80
+
81
+ app = typer.Typer(
82
+ name="odoo",
83
+ cls=_RootGroup,
84
+ help=(
85
+ "Odoo JSON-RPC CLI for AI agents and scripts.\n\n"
86
+ "stdout carries only data: raw Odoo JSON when piped, a table on a terminal. "
87
+ "Errors, warnings and write logs are single JSON lines on stderr. "
88
+ "Exit codes: 0 ok, 1 Odoo error, 2 usage, 3 connection/auth, 4 refused by a guard.\n\n"
89
+ "New here? Run: odoo agent-guide"
90
+ ),
91
+ no_args_is_help=True,
92
+ add_completion=False,
93
+ rich_markup_mode=None,
94
+ context_settings={"help_option_names": ["-h", "--help"]},
95
+ )
96
+
97
+
98
+ @dataclass
99
+ class Session:
100
+ profile_name: str | None
101
+ fmt: str | None
102
+ redact: bool
103
+ include_sensitive: bool
104
+ timeout: float
105
+ verbose: bool
106
+ assume_yes: bool
107
+ context: dict[str, Any] = field(default_factory=dict)
108
+ verify_ssl: bool = True
109
+ debug: bool = False
110
+ env: Mapping[str, str] = field(default_factory=lambda: dict(os.environ))
111
+ config: Path = field(default_factory=lambda: config_path(os.environ))
112
+
113
+ def profile(self) -> Profile:
114
+ return resolve_profile(self.profile_name, self.env, self.config)
115
+
116
+
117
+ def _version_callback(value: bool) -> None:
118
+ if value:
119
+ typer.echo(f"odoo {__version__} (odoo-agent-cli)")
120
+ raise typer.Exit()
121
+
122
+
123
+ @app.callback()
124
+ def _root(
125
+ ctx: typer.Context,
126
+ profile: str | None = typer.Option(
127
+ None,
128
+ "--profile",
129
+ "-p",
130
+ help="Profile name (see 'odoo profile'). Beats ODOO_PROFILE and ODOO_* env.",
131
+ ),
132
+ fmt: str | None = typer.Option(
133
+ None,
134
+ "--format",
135
+ "-f",
136
+ help="json | jsonl | table | csv. Default: table on a TTY, json otherwise.",
137
+ ),
138
+ no_redact: bool = typer.Option(
139
+ False, "--no-redact", help="Do not mask password/api_key/secret field values."
140
+ ),
141
+ include_sensitive: bool = typer.Option(
142
+ False,
143
+ "--include-sensitive",
144
+ help="Allow sensitive models (ir.config_parameter, ir.mail_server, ir.cron, ...).",
145
+ ),
146
+ timeout: float = typer.Option(30.0, "--timeout", help="Seconds per RPC call."),
147
+ verbose: bool = typer.Option(
148
+ False, "--verbose", help="Include the Odoo debug payload in errors."
149
+ ),
150
+ context: str | None = typer.Option(
151
+ None,
152
+ "--context",
153
+ help='Odoo context as JSON, e.g. \'{"lang": "fr_BE"}\'. Merged into every call.',
154
+ ),
155
+ include_archived: bool = typer.Option(
156
+ False, "--include-archived", help="Also match archived records (context active_test=false)."
157
+ ),
158
+ company: int | None = typer.Option(
159
+ None, "--company", help="Company id to operate in (context allowed_company_ids)."
160
+ ),
161
+ lang: str | None = typer.Option(None, "--lang", help="Language code for labels, e.g. fr_BE."),
162
+ insecure: bool = typer.Option(
163
+ False, "--insecure", help="Skip TLS certificate verification (self-signed on-prem)."
164
+ ),
165
+ debug: bool = typer.Option(
166
+ False,
167
+ "--debug",
168
+ help="Log every RPC call (method, id, duration, retries) as JSON on stderr.",
169
+ ),
170
+ _version: bool = typer.Option(
171
+ False, "--version", callback=_version_callback, is_eager=True, help="Print version."
172
+ ),
173
+ ) -> None:
174
+ if fmt is not None and fmt not in FORMATS:
175
+ raise typer.BadParameter(f"--format must be one of {', '.join(FORMATS)}")
176
+ ctx.obj = Session(
177
+ profile_name=profile,
178
+ fmt=fmt,
179
+ redact=not no_redact,
180
+ include_sensitive=include_sensitive,
181
+ timeout=timeout,
182
+ verbose=verbose,
183
+ assume_yes=env_flag(os.environ, ENV_ASSUME_YES),
184
+ context=build_context(context, include_archived, company, lang),
185
+ verify_ssl=not insecure,
186
+ debug=debug,
187
+ )
188
+
189
+
190
+ def build_context(
191
+ context_json: str | None, include_archived: bool, company: int | None, lang: str | None
192
+ ) -> dict[str, Any]:
193
+ """Compose the Odoo context from the convenience flags; explicit JSON keys come first."""
194
+ ctx: dict[str, Any] = {}
195
+ if context_json:
196
+ try:
197
+ parsed = json.loads(context_json)
198
+ except ValueError as e:
199
+ raise typer.BadParameter(f"--context must be a JSON object: {e}") from e
200
+ if not isinstance(parsed, dict):
201
+ raise typer.BadParameter("--context must be a JSON object")
202
+ ctx.update(parsed)
203
+ if lang:
204
+ ctx["lang"] = lang
205
+ if company is not None:
206
+ ctx["allowed_company_ids"] = [company]
207
+ if include_archived:
208
+ ctx["active_test"] = False
209
+ return ctx
210
+
211
+
212
+ class _JsonLineFormatter(logging.Formatter):
213
+ def format(self, record: logging.LogRecord) -> str:
214
+ return json.dumps(
215
+ {
216
+ "log": {
217
+ "level": record.levelname,
218
+ "logger": record.name,
219
+ "message": record.getMessage(),
220
+ }
221
+ },
222
+ ensure_ascii=False,
223
+ )
224
+
225
+
226
+ @contextlib.contextmanager
227
+ def debug_logging(enabled: bool) -> Iterator[None]:
228
+ """Attach a JSON-lines stderr handler to the ``odoocli`` logger for one command."""
229
+ if not enabled:
230
+ yield
231
+ return
232
+ root = logging.getLogger("odoocli")
233
+ handler = logging.StreamHandler(sys.stderr)
234
+ handler.setFormatter(_JsonLineFormatter())
235
+ previous_level = root.level
236
+ root.addHandler(handler)
237
+ root.setLevel(logging.DEBUG)
238
+ try:
239
+ yield
240
+ finally:
241
+ root.removeHandler(handler)
242
+ root.setLevel(previous_level)
243
+ handler.flush()
244
+
245
+
246
+ def session(ctx: typer.Context) -> Session:
247
+ obj = ctx.obj
248
+ assert isinstance(obj, Session)
249
+ return obj
250
+
251
+
252
+ def warn(payload: dict[str, Any]) -> None:
253
+ """One JSON line on stderr (warnings, write logs)."""
254
+ sys.stderr.write(json.dumps(payload, ensure_ascii=False) + "\n")
255
+ sys.stderr.flush()
256
+
257
+
258
+ def fail(err: OdooError, verbose: bool) -> None:
259
+ body = err.to_dict()
260
+ if verbose and err.data:
261
+ body["debug"] = err.data.get("debug")
262
+ warn({"error": body})
263
+ raise typer.Exit(err.exit_code)
264
+
265
+
266
+ def check_model(sess: Session, profile: Profile, model: str) -> None:
267
+ if is_sensitive_model(model) and not (sess.include_sensitive or profile.allow_sensitive):
268
+ raise OdooRefusedError(
269
+ f"Model {model!r} is sensitive (secrets or code execution). "
270
+ "Pass --include-sensitive or set allow_sensitive = true on the profile.",
271
+ code="sensitive_model",
272
+ )
273
+
274
+
275
+ def require_writes(profile: Profile) -> None:
276
+ if not profile.allow_writes:
277
+ raise OdooRefusedError(
278
+ f"Writes are disabled for {profile.source}. Set allow_writes = true on the profile "
279
+ "or ODOO_ALLOW_WRITES=1 in the environment.",
280
+ code="writes_disabled",
281
+ )
282
+
283
+
284
+ def require_yes(sess: Session, yes: bool, what: str) -> None:
285
+ if not (yes or sess.assume_yes):
286
+ raise OdooRefusedError(
287
+ f"{what} needs explicit confirmation: pass --yes (or ODOO_ASSUME_YES=1).",
288
+ code="confirmation_required",
289
+ )
290
+
291
+
292
+ def run(ctx: typer.Context, fn: Callable[[AsyncOdooClient, Profile], Awaitable[T]]) -> T:
293
+ """Resolve the profile, open a client, run ``fn``, map errors to exit codes.
294
+
295
+ Argument parsing that can raise ``OdooUsageError`` belongs inside ``fn`` so
296
+ it is mapped to exit code 2 like every other ``OdooError``.
297
+ """
298
+ sess = session(ctx)
299
+
300
+ async def _go() -> T:
301
+ profile = sess.profile()
302
+ async with AsyncOdooClient(
303
+ profile.url,
304
+ profile.database,
305
+ profile.login,
306
+ profile.api_key,
307
+ timeout=sess.timeout,
308
+ verify_ssl=sess.verify_ssl and profile.verify_ssl,
309
+ context=sess.context,
310
+ ) as client:
311
+ return await fn(client, profile)
312
+
313
+ try:
314
+ with debug_logging(sess.debug):
315
+ return asyncio.run(_go())
316
+ except OdooError as e:
317
+ fail(e, sess.verbose)
318
+ raise AssertionError("unreachable") from e
319
+
320
+
321
+ def emit(ctx: typer.Context, data: Any, *, table_rows: list[dict[str, Any]] | None = None) -> None:
322
+ sess = session(ctx)
323
+ if sess.redact:
324
+ data = redact(data)
325
+ table_rows = redact(table_rows) if table_rows is not None else None
326
+ fmt = detect_format(sess.fmt, sys.stdout.isatty())
327
+ text = render(data, fmt, table_rows=table_rows)
328
+ if text:
329
+ typer.echo(text)
330
+
331
+
332
+ def main() -> None:
333
+ app()
334
+
335
+
336
+ # Command modules register themselves on ``app``; imported last to avoid cycles.
337
+ from odoocli.cli import guide_cmd, profile_cmds, read_cmds, write_cmds # noqa: E402,F401
@@ -0,0 +1,19 @@
1
+ """odoo agent-guide: print the packaged guide written for AI agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import resources
6
+
7
+ import typer
8
+
9
+ from odoocli.cli.app import app
10
+
11
+
12
+ def guide_text() -> str:
13
+ return resources.files("odoocli").joinpath("AGENT_GUIDE.md").read_text(encoding="utf-8")
14
+
15
+
16
+ @app.command("agent-guide")
17
+ def agent_guide() -> None:
18
+ """Print the usage guide written for AI agents (conventions, pitfalls, recipes)."""
19
+ typer.echo(guide_text())
odoocli/cli/output.py ADDED
@@ -0,0 +1,105 @@
1
+ """Render data for stdout. json is the source of truth; table and csv are for humans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+ import json
8
+ from typing import Any
9
+
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ FORMATS = ("json", "jsonl", "table", "csv")
14
+ _MAX_CELL = 60
15
+
16
+
17
+ def detect_format(explicit: str | None, isatty: bool) -> str:
18
+ if explicit:
19
+ return explicit
20
+ return "table" if isatty else "json"
21
+
22
+
23
+ def _is_m2o(value: Any) -> bool:
24
+ return (
25
+ isinstance(value, list)
26
+ and len(value) == 2
27
+ and isinstance(value[0], int)
28
+ and isinstance(value[1], str)
29
+ )
30
+
31
+
32
+ def _cell(value: Any) -> str:
33
+ if value is False or value is None:
34
+ return ""
35
+ if _is_m2o(value):
36
+ return f"{value[1]} (#{value[0]})"
37
+ text = json.dumps(value, ensure_ascii=False) if isinstance(value, list | dict) else str(value)
38
+ return text if len(text) <= _MAX_CELL else text[: _MAX_CELL - 3] + "..."
39
+
40
+
41
+ def _columns(rows: list[dict[str, Any]]) -> list[str]:
42
+ cols: list[str] = []
43
+ for row in rows:
44
+ for key in row:
45
+ if key not in cols:
46
+ cols.append(key)
47
+ if "id" in cols:
48
+ cols.remove("id")
49
+ cols.insert(0, "id")
50
+ return cols
51
+
52
+
53
+ def _as_rows(data: Any, table_rows: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
54
+ if table_rows is not None:
55
+ return table_rows
56
+ if isinstance(data, list) and all(isinstance(r, dict) for r in data):
57
+ return data
58
+ if isinstance(data, dict):
59
+ return [data]
60
+ return None
61
+
62
+
63
+ def _table(rows: list[dict[str, Any]]) -> str:
64
+ cols = _columns(rows)
65
+ table = Table(show_lines=False, header_style="bold")
66
+ for col in cols:
67
+ table.add_column(col)
68
+ for row in rows:
69
+ table.add_row(*(_cell(row.get(c)) for c in cols))
70
+ buf = io.StringIO()
71
+ console = Console(file=buf, width=200, force_terminal=False, color_system=None)
72
+ console.print(table)
73
+ return buf.getvalue().rstrip("\n")
74
+
75
+
76
+ def _csv(rows: list[dict[str, Any]]) -> str:
77
+ cols = _columns(rows)
78
+ buf = io.StringIO()
79
+ writer = csv.DictWriter(buf, fieldnames=cols, lineterminator="\n")
80
+ writer.writeheader()
81
+ for row in rows:
82
+ writer.writerow(
83
+ {
84
+ c: (json.dumps(v, ensure_ascii=False) if isinstance(v, list | dict) else v)
85
+ for c, v in row.items()
86
+ if c in cols
87
+ }
88
+ )
89
+ return buf.getvalue().rstrip("\n")
90
+
91
+
92
+ def render(data: Any, fmt: str, *, table_rows: list[dict[str, Any]] | None = None) -> str:
93
+ """Return the text to print for ``data`` in ``fmt``. Empty string means print nothing."""
94
+ if fmt == "json":
95
+ return json.dumps(data, ensure_ascii=False, indent=2)
96
+ if fmt == "jsonl":
97
+ items = data if isinstance(data, list) else [data]
98
+ return "\n".join(json.dumps(i, ensure_ascii=False) for i in items)
99
+ rows = _as_rows(data, table_rows)
100
+ if rows is None:
101
+ # Scalars and unknown shapes: fall back to compact JSON.
102
+ return json.dumps(data, ensure_ascii=False)
103
+ if not rows:
104
+ return ""
105
+ return _table(rows) if fmt == "table" else _csv(rows)
@@ -0,0 +1,116 @@
1
+ """odoo profile add | list | test | remove | path"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import typer
8
+
9
+ from odoocli.cli.app import app, emit, fail, session
10
+ from odoocli.cli.read_cmds import info
11
+ from odoocli.config import load_profiles, remove_profile, save_profile
12
+ from odoocli.errors import OdooConnectionError
13
+
14
+ profile_app = typer.Typer(
15
+ help="Manage named connections stored in the config file (mode 0600).",
16
+ no_args_is_help=True,
17
+ rich_markup_mode=None,
18
+ )
19
+ app.add_typer(profile_app, name="profile")
20
+
21
+
22
+ @profile_app.command("add")
23
+ def profile_add(
24
+ ctx: typer.Context,
25
+ name: str = typer.Argument(...),
26
+ url: str = typer.Option(..., "--url", help="https://your-odoo.example.com"),
27
+ db: str = typer.Option(..., "--db", help="Database name"),
28
+ login: str = typer.Option(..., "--login", help="User login (email)"),
29
+ api_key: str | None = typer.Option(
30
+ None, "--api-key", help="API key or password, stored in the file."
31
+ ),
32
+ api_key_env: str | None = typer.Option(
33
+ None, "--api-key-env", help="Name of an env var holding the key (nothing stored)."
34
+ ),
35
+ allow_writes: bool = typer.Option(
36
+ False, "--allow-writes", help="Enable create/write/unlink/call on this profile."
37
+ ),
38
+ allow_sensitive: bool = typer.Option(
39
+ False, "--allow-sensitive", help="Allow sensitive models on this profile."
40
+ ),
41
+ no_verify_ssl: bool = typer.Option(
42
+ False, "--no-verify-ssl", help="Skip TLS verification (self-signed on-prem)."
43
+ ),
44
+ test: bool = typer.Option(False, "--test", help="Run 'odoo info' with the new profile."),
45
+ ) -> None:
46
+ """Add or replace a profile."""
47
+ if bool(api_key) == bool(api_key_env):
48
+ raise typer.BadParameter("Give exactly one of --api-key or --api-key-env")
49
+ sess = session(ctx)
50
+ save_profile(
51
+ sess.config,
52
+ name,
53
+ {
54
+ "url": url.rstrip("/"),
55
+ "database": db,
56
+ "login": login,
57
+ "api_key": api_key,
58
+ "api_key_env": api_key_env,
59
+ "allow_writes": allow_writes,
60
+ "allow_sensitive": allow_sensitive,
61
+ "verify_ssl": False if no_verify_ssl else None,
62
+ },
63
+ )
64
+ if test:
65
+ sess.profile_name = name
66
+ info(ctx, modules=False)
67
+
68
+
69
+ @profile_app.command("list")
70
+ def profile_list(ctx: typer.Context) -> None:
71
+ """List profiles. Keys are never printed."""
72
+ sess = session(ctx)
73
+ rows: list[dict[str, Any]] = []
74
+ for name, data in load_profiles(sess.config).items():
75
+ key = "***" if data.get("api_key") else f"${data.get('api_key_env', '')}"
76
+ rows.append(
77
+ {
78
+ "name": name,
79
+ "url": data.get("url"),
80
+ "database": data.get("database"),
81
+ "login": data.get("login"),
82
+ "key": key,
83
+ "allow_writes": bool(data.get("allow_writes", False)),
84
+ "allow_sensitive": bool(data.get("allow_sensitive", False)),
85
+ "verify_ssl": bool(data.get("verify_ssl", True)),
86
+ }
87
+ )
88
+ emit(ctx, rows)
89
+
90
+
91
+ @profile_app.command("test")
92
+ def profile_test(ctx: typer.Context, name: str | None = typer.Argument(None)) -> None:
93
+ """Authenticate with a profile (default: the one that would be used) and print server info."""
94
+ sess = session(ctx)
95
+ if name:
96
+ sess.profile_name = name
97
+ info(ctx, modules=False)
98
+
99
+
100
+ @profile_app.command("remove")
101
+ def profile_remove(ctx: typer.Context, name: str = typer.Argument(...)) -> None:
102
+ """Delete a profile."""
103
+ sess = session(ctx)
104
+ if not remove_profile(sess.config, name):
105
+ fail(
106
+ OdooConnectionError(
107
+ f"Profile {name!r} not found in {sess.config}", code="no_connection"
108
+ ),
109
+ False,
110
+ )
111
+
112
+
113
+ @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))