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/errors.py ADDED
@@ -0,0 +1,99 @@
1
+ """Typed exceptions for odoocli, classified from Odoo JSON-RPC error payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class OdooError(Exception):
9
+ """Base error. ``exit_code`` is what the CLI returns for this class."""
10
+
11
+ exit_code: int = 1
12
+ default_code: str = "odoo_error"
13
+
14
+ def __init__(
15
+ self,
16
+ message: str,
17
+ *,
18
+ code: str | None = None,
19
+ data: dict[str, Any] | None = None,
20
+ ) -> None:
21
+ self.message = message
22
+ self.code = code or self.default_code
23
+ self.data = data
24
+ super().__init__(message)
25
+
26
+ def to_dict(self) -> dict[str, Any]:
27
+ out: dict[str, Any] = {"code": self.code, "message": self.message}
28
+ if self.data:
29
+ # Keep what helps a caller decide; drop the server traceback.
30
+ odoo = {k: v for k, v in self.data.items() if k in ("name", "message", "arguments")}
31
+ if odoo:
32
+ out["odoo"] = odoo
33
+ return out
34
+
35
+
36
+ class OdooConnectionError(OdooError):
37
+ """Cannot reach or talk JSON-RPC with the server (network, HTTP status, bad URL)."""
38
+
39
+ exit_code = 3
40
+ default_code = "connection_error"
41
+
42
+
43
+ class OdooAuthError(OdooError):
44
+ """Credentials rejected."""
45
+
46
+ exit_code = 3
47
+ default_code = "auth_failed"
48
+
49
+
50
+ class OdooAccessError(OdooError):
51
+ """Authenticated but not allowed (ACL, record rule)."""
52
+
53
+ default_code = "access_error"
54
+
55
+
56
+ class OdooValidationError(OdooError):
57
+ """Business rule rejected the call (ValidationError, UserError)."""
58
+
59
+ default_code = "validation_error"
60
+
61
+
62
+ class OdooMissingError(OdooError):
63
+ """Record does not exist."""
64
+
65
+ default_code = "missing_record"
66
+
67
+
68
+ class OdooRefusedError(OdooError):
69
+ """The CLI itself refused the operation (guards), Odoo was never called."""
70
+
71
+ exit_code = 4
72
+ default_code = "refused"
73
+
74
+
75
+ class OdooUsageError(OdooError):
76
+ """Bad arguments that the CLI can detect before calling Odoo."""
77
+
78
+ exit_code = 2
79
+ default_code = "usage_error"
80
+
81
+
82
+ _BY_EXCEPTION_NAME: dict[str, type[OdooError]] = {
83
+ "odoo.exceptions.AccessDenied": OdooAuthError,
84
+ "odoo.exceptions.AccessError": OdooAccessError,
85
+ "odoo.exceptions.ValidationError": OdooValidationError,
86
+ "odoo.exceptions.UserError": OdooValidationError,
87
+ "odoo.exceptions.RedirectWarning": OdooValidationError,
88
+ "odoo.exceptions.MissingError": OdooMissingError,
89
+ }
90
+
91
+
92
+ def classify_rpc_error(error: dict[str, Any]) -> OdooError:
93
+ """Map the ``error`` object of a JSON-RPC response to a typed exception."""
94
+ data = error.get("data") or {}
95
+ name = str(data.get("name", ""))
96
+ message = str(data.get("message") or error.get("message") or "Unknown Odoo error")
97
+ cls = _BY_EXCEPTION_NAME.get(name, OdooError)
98
+ code = None if cls is not OdooError else "rpc_error"
99
+ return cls(message, code=code, data=data or None)
odoocli/lenient.py ADDED
@@ -0,0 +1,93 @@
1
+ """Opt-in search_read that strips fields Odoo rejects and retries (version drift)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from odoocli.client import AsyncOdooClient, Domain
10
+ from odoocli.domain import strip_field_from_domain
11
+ from odoocli.errors import OdooError
12
+
13
+ # "Invalid field 'date_planned'" and "Invalid field account.account.deprecated in condition"
14
+ _INVALID_FIELD_RE = re.compile(r"Invalid field '?([\w.]+)'?", re.IGNORECASE)
15
+ # "Cannot convert qty_available to SQL because it is not stored" and
16
+ # "Field 'is_storable' cannot be used in domain"
17
+ _NON_STORED_RE = re.compile(
18
+ r"Cannot convert ([\w.]+) to SQL|[Ff]ield '?([\w.]+)'? cannot be used in domain",
19
+ re.IGNORECASE,
20
+ )
21
+
22
+ Warn = Callable[[dict[str, Any]], None]
23
+
24
+
25
+ def _strip_order(order: str, bad_field: str) -> str | None:
26
+ parts = [p.strip() for p in order.split(",") if p.strip() and p.strip().split()[0] != bad_field]
27
+ return ", ".join(parts) if parts else None
28
+
29
+
30
+ async def lenient_search_read(
31
+ client: AsyncOdooClient,
32
+ model: str,
33
+ domain: Domain | None,
34
+ fields: list[str] | None,
35
+ limit: int | None,
36
+ offset: int,
37
+ order: str | None,
38
+ *,
39
+ max_retries: int = 3,
40
+ on_warning: Warn | None = None,
41
+ ) -> list[dict[str, Any]]:
42
+ """Like ``client.search_read`` but removes rejected fields and retries.
43
+
44
+ Every removal is reported through ``on_warning`` as
45
+ ``{"warning": <kind>, "field": <name>, "from": ["fields" | "domain" | "order", ...]}``.
46
+ """
47
+ domain = list(domain or [])
48
+ fields = list(fields) if fields else None
49
+ for _ in range(max_retries + 1):
50
+ try:
51
+ return await client.search_read(model, domain, fields, limit, offset, order)
52
+ except OdooError as e:
53
+ text = e.message
54
+ invalid = _INVALID_FIELD_RE.search(text)
55
+ if invalid:
56
+ bad = invalid.group(1).split(".")[-1]
57
+ removed: list[str] = []
58
+ if fields and bad in fields:
59
+ fields = [f for f in fields if f != bad] or None
60
+ removed.append("fields")
61
+ if bad in str(domain):
62
+ domain = strip_field_from_domain(domain, bad)
63
+ removed.append("domain")
64
+ if order and bad in order:
65
+ order = _strip_order(order, bad)
66
+ removed.append("order")
67
+ if removed:
68
+ if on_warning:
69
+ on_warning(
70
+ {"warning": "invalid_field_removed", "field": bad, "from": removed}
71
+ )
72
+ continue
73
+ raise
74
+ non_stored = _NON_STORED_RE.search(text)
75
+ if non_stored:
76
+ bad = (non_stored.group(1) or non_stored.group(2) or "").split(".")[-1]
77
+ removed = []
78
+ if bad and bad in str(domain):
79
+ domain = strip_field_from_domain(domain, bad)
80
+ removed.append("domain")
81
+ if bad and order and bad in order:
82
+ order = _strip_order(order, bad)
83
+ removed.append("order")
84
+ if removed:
85
+ if on_warning:
86
+ on_warning(
87
+ {"warning": "non_stored_field_removed", "field": bad, "from": removed}
88
+ )
89
+ continue
90
+ raise
91
+ raise OdooError(
92
+ f"Gave up repairing the query after {max_retries} retries", code="retry_exhausted"
93
+ )
odoocli/py.typed ADDED
File without changes
odoocli/security.py ADDED
@@ -0,0 +1,83 @@
1
+ """Guards ported from UpBoard's connector: sensitive models, secret fields, read-safe methods."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ # Models holding secrets or allowing code execution. Refused unless the
8
+ # caller explicitly opts in (--include-sensitive / allow_sensitive).
9
+ SENSITIVE_MODELS: frozenset[str] = frozenset(
10
+ {
11
+ "ir.config_parameter",
12
+ "ir.property",
13
+ "ir.cron",
14
+ "ir.actions.server",
15
+ "ir.rule",
16
+ "ir.model.access",
17
+ "base.module.update",
18
+ "base.module.upgrade",
19
+ "base.language.install",
20
+ "res.users.apikeys",
21
+ "auth.totp.device",
22
+ "ir.mail_server",
23
+ "fetchmail.server",
24
+ }
25
+ )
26
+
27
+ # Field-name substrings whose VALUE is masked in output (case-insensitive).
28
+ SENSITIVE_FIELD_PATTERNS: tuple[str, ...] = (
29
+ "password",
30
+ "passwd",
31
+ "smtp_pass",
32
+ "api_key",
33
+ "apikey",
34
+ "secret",
35
+ "private_key",
36
+ "totp_secret",
37
+ )
38
+
39
+ # Methods that never change data: callable without allow_writes or --yes.
40
+ READ_SAFE_METHODS: frozenset[str] = frozenset(
41
+ {
42
+ "search",
43
+ "search_read",
44
+ "search_count",
45
+ "read",
46
+ "read_group",
47
+ "fields_get",
48
+ "name_search",
49
+ "name_get",
50
+ "default_get",
51
+ "get_metadata",
52
+ "check_access_rights",
53
+ "get_views",
54
+ "web_search_read",
55
+ "web_read_group",
56
+ "has_group",
57
+ "get_external_id",
58
+ }
59
+ )
60
+
61
+ REDACTED = "[redacted]"
62
+
63
+
64
+ def is_sensitive_model(model: str) -> bool:
65
+ return model in SENSITIVE_MODELS
66
+
67
+
68
+ def is_sensitive_field(name: str) -> bool:
69
+ lowered = name.lower()
70
+ return any(p in lowered for p in SENSITIVE_FIELD_PATTERNS)
71
+
72
+
73
+ def redact(data: Any) -> Any:
74
+ """Replace values of secret-named keys, recursively, in lists and dicts."""
75
+ if isinstance(data, list):
76
+ return [redact(i) for i in data]
77
+ if isinstance(data, dict):
78
+ return {k: (REDACTED if is_sensitive_field(str(k)) else redact(v)) for k, v in data.items()}
79
+ return data
80
+
81
+
82
+ def is_read_safe_method(method: str) -> bool:
83
+ return method in READ_SAFE_METHODS
odoocli/sync.py ADDED
@@ -0,0 +1,126 @@
1
+ """Blocking wrapper around AsyncOdooClient for plain scripts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import threading
8
+ from collections.abc import Coroutine
9
+ from types import TracebackType
10
+ from typing import Any, TypeVar
11
+
12
+ from odoocli.client import AsyncOdooClient, Domain
13
+
14
+ T = TypeVar("T")
15
+
16
+
17
+ class OdooClient:
18
+ """Same API as :class:`AsyncOdooClient`, without ``await``.
19
+
20
+ Runs a private event loop on a daemon thread so the underlying httpx
21
+ client keeps one connection pool for the life of the object.
22
+ """
23
+
24
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
25
+ self._async = AsyncOdooClient(*args, **kwargs)
26
+ self._loop = asyncio.new_event_loop()
27
+ self._thread = threading.Thread(
28
+ target=self._loop.run_forever, name="odoocli-loop", daemon=True
29
+ )
30
+ self._thread.start()
31
+ self._closed = False
32
+
33
+ def _run(self, coro: Coroutine[Any, Any, T]) -> T:
34
+ if self._closed:
35
+ coro.close()
36
+ raise RuntimeError("OdooClient is closed")
37
+ return asyncio.run_coroutine_threadsafe(coro, self._loop).result()
38
+
39
+ # ----- lifecycle -----
40
+
41
+ def close(self) -> None:
42
+ if self._closed:
43
+ return
44
+ self._closed = True
45
+ asyncio.run_coroutine_threadsafe(self._async.close(), self._loop).result()
46
+ self._loop.call_soon_threadsafe(self._loop.stop)
47
+ self._thread.join(timeout=5)
48
+ self._loop.close()
49
+
50
+ def __enter__(self) -> OdooClient:
51
+ return self
52
+
53
+ def __exit__(
54
+ self,
55
+ exc_type: type[BaseException] | None,
56
+ exc: BaseException | None,
57
+ tb: TracebackType | None,
58
+ ) -> None:
59
+ self.close()
60
+
61
+ def __del__(self) -> None:
62
+ with contextlib.suppress(Exception): # never raise from a destructor
63
+ self.close()
64
+
65
+ # ----- mirror -----
66
+
67
+ @property
68
+ def uid(self) -> int | None:
69
+ return self._async.uid
70
+
71
+ @property
72
+ def context(self) -> dict[str, Any]:
73
+ """Client-wide Odoo context, merged into every call (mutable)."""
74
+ return self._async.context
75
+
76
+ def version(self) -> dict[str, Any]:
77
+ return self._run(self._async.version())
78
+
79
+ def authenticate(self) -> int:
80
+ return self._run(self._async.authenticate())
81
+
82
+ def execute(self, model: str, method: str, *args: Any, **kwargs: Any) -> Any:
83
+ return self._run(self._async.execute(model, method, *args, **kwargs))
84
+
85
+ def search(
86
+ self,
87
+ model: str,
88
+ domain: Domain | None = None,
89
+ limit: int | None = None,
90
+ offset: int = 0,
91
+ order: str | None = None,
92
+ ) -> list[int]:
93
+ return self._run(self._async.search(model, domain, limit, offset, order))
94
+
95
+ def search_read(
96
+ self,
97
+ model: str,
98
+ domain: Domain | None = None,
99
+ fields: list[str] | None = None,
100
+ limit: int | None = None,
101
+ offset: int = 0,
102
+ order: str | None = None,
103
+ ) -> list[dict[str, Any]]:
104
+ return self._run(self._async.search_read(model, domain, fields, limit, offset, order))
105
+
106
+ def search_count(self, model: str, domain: Domain | None = None) -> int:
107
+ return self._run(self._async.search_count(model, domain))
108
+
109
+ def read(
110
+ self, model: str, ids: list[int], fields: list[str] | None = None
111
+ ) -> list[dict[str, Any]]:
112
+ return self._run(self._async.read(model, ids, fields))
113
+
114
+ def create(self, model: str, values: dict[str, Any] | list[dict[str, Any]]) -> Any:
115
+ return self._run(self._async.create(model, values))
116
+
117
+ def write(self, model: str, ids: list[int], values: dict[str, Any]) -> bool:
118
+ return self._run(self._async.write(model, ids, values))
119
+
120
+ def unlink(self, model: str, ids: list[int]) -> bool:
121
+ return self._run(self._async.unlink(model, ids))
122
+
123
+ def fields_get(
124
+ self, model: str, attributes: list[str] | None = None
125
+ ) -> dict[str, dict[str, Any]]:
126
+ return self._run(self._async.fields_get(model, attributes))