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/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
|
odoocli/cli/guide_cmd.py
ADDED
|
@@ -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))
|