Python SDK
A typed, sync + async Python client for ODXProxy, built on httpx with HTTP/2 connection reuse.
terrakernel-odxproxyclient is the official Python client for ODXProxy. It exposes
sync and async surfaces side by side (same constructor, same method names),
is built on httpx with HTTP/2 connection reuse, and is fully PEP 561 typed.
Source: terrakernel/ODXProxyClient-Python · PyPI terrakernel-odxproxyclient · Python 3.12+ · v2 API since 0.9.0.
Install
pip install terrakernel-odxproxyclientImport from terrakernel.odxproxyclient:
from terrakernel.odxproxyclient import ODXProxyClientConstruct a client, bind an instance
Create one ODXProxyClient per process (it owns an httpx.Client — the TCP/TLS
pool is the main performance knob), then bind an Odoo instance with for_instance()
to get a reusable Session. Per-instance details travel in every request body, so
binding once and reusing the session is the canonical pattern.
from terrakernel.odxproxyclient import ODXProxyClient
with ODXProxyClient(
"https://your-proxy.example.com", # proxy base URL
"<proxy x-api-key>", # the PROXY's key
default_timeout_secs=15,
) as client:
erp = client.for_instance(
url="https://erp.example.com",
db="prod",
user_id=2,
api_key="<odoo user api key>", # the Odoo USER's key
)
partners = erp.search_read(
"res.partner",
params=[[["is_company", "=", True]]],
keyword={"fields": ["id", "name", "email"], "limit": 50, "context": {"tz": "UTC"}},
)Two different secrets
The client's api_key (2nd positional arg) is the proxy's x-api-key;
for_instance(..., api_key=...) is the Odoo user's API key. They are never
the same value — don't conflate them.
Methods return the JSON-RPC result directly (a JsonValue) and raise a typed
exception on any error — there's no envelope to unwrap. params (a list) and
keyword (a dict) are passed through to Odoo unchanged; you build the raw Odoo
shapes yourself.
Async
AsyncODXProxyClient / AsyncSession mirror the sync API exactly — same args, same
methods — just await the calls inside async with:
from terrakernel.odxproxyclient import AsyncODXProxyClient
async with AsyncODXProxyClient("https://your-proxy.example.com", "<proxy x-api-key>") as client:
erp = client.for_instance(url="https://erp.example.com", db="prod", user_id=2, api_key="...")
count = await erp.search_count("res.partner", params=[[["is_company", "=", True]]])The 9 actions
Each allowed action is a method on the Session. All take
keyword-only params, keyword, request_id, and timeout_secs (per-call
override); call_method additionally takes fn_name positionally.
| Action | Method |
|---|---|
search_count | session.search_count(model_id, *, params, keyword, ...) |
search | session.search(...) |
read | session.read(...) |
fields_get | session.fields_get(...) |
search_read | session.search_read(...) |
create | session.create(...) |
write | session.write(...) |
unlink | session.unlink(...) |
call_method | session.call_method(model_id, fn_name, *, params, keyword, ...) |
new_id = erp.create("res.partner", params=[{"name": "Acme Inc", "is_company": True}])
erp.write("res.partner", params=[[new_id], {"name": "Acme LLC"}])
erp.unlink("res.partner", params=[[new_id]])
# call_method — fn_name is required and positional
erp.call_method("sale.order", "action_confirm", params=[[42]])Calling call_method with an empty fn_name raises ValueError client-side (the
proxy would otherwise return code -32002). Anything outside the 9 actions must
go through call_method. Unlike some of the other SDKs, this client does not
auto-strip pagination keys — pass only the keyword fields the action expects.
v2 sessions (Odoo 19+)
From 0.9.0 the client also speaks ODXProxy's v2 API (Odoo's
JSON-2; needs ODXProxy 0.9.0+ and Odoo 19+). A v2 session comes from the same
client with for_instance_v2 — no user_id, and an optional default context
merged into every call (a call's own context= keys win):
erp2 = client.for_instance_v2(
url="https://erp.example.com",
db="prod",
api_key="<odoo user api key>", # an Odoo API key, not a password
context={"lang": "en_US", "tz": "Asia/Jakarta", "allowed_company_ids": [1]},
)
partners = erp2.search_read("res.partner", [["is_company", "=", True]],
fields=["name", "email"], limit=20, order="name asc")
ids = erp2.search("res.partner", [["customer_rank", ">", 0]], limit=10) # list[int]
count = erp2.search_count("res.partner", []) # int
rows = erp2.read("res.partner", ids, fields=["name"])
schema = erp2.fields_get("res.partner", attributes=["type", "string"])
new_ids = erp2.create("res.partner", [{"name": "Acme"}, {"name": "Globex"}]) # [41, 42]
one = erp2.create_one("res.partner", {"name": "Initech"}) # 43
erp2.write("res.partner", new_ids, {"comment": "via v2"})
erp2.unlink("res.partner", new_ids)
erp2.call_method("account.move", "action_post", ids=[7]) # record method
erp2.call_method("res.partner", "name_search", kwargs={"name": "Acm", "limit": 5})- Named arguments only. There's no
paramsorkeyword: each argument goes to Odoo under its Python parameter name, and the domain is the list itself (no extra wrapping). Arguments leftNoneare omitted, so Odoo's defaults apply. createalways returns a list of ids;create_onereturns oneint.call_methodtakesids=for record methods only, and every other argument inkwargs={...}under its Python name.- The async client has the same methods:
aclient.for_instance_v2(...)returns anAsyncSessionV2. client.supports_v2(url)checks an instance once and caches the answer;client.odoo_version_v2(url)returns{"version_info": [...], "version": "..."}.
Operational endpoints
These live on the client (not the session) and need no Odoo instance:
client.license() # -> LicenseInfo(licensee, valid_until, is_valid)
client.about() # -> BuildInfo(build, version)
client.metrics() # -> str (Prometheus text)
client.odoo_version("https://erp.example.com") # -> JsonValuelicense() returns a typed LicenseInfo, not a JSON-RPC envelope — the proxy's
/_/license endpoint emits a flat object. about, license, and metrics
send no API key.
Typed errors
Every failure raises a subclass of ODXProxyError, one per proxy error code, so you
can catch exactly what you need. Each carries .code, .message, .data,
.http_status, and .request_id. See the full error catalog.
| Exception | Proxy code | Meaning |
|---|---|---|
AuthError | -32000 | Missing or wrong x-api-key. |
InvalidActionError | -32001 | Action not in the allowlist. |
MissingFnNameError | -32002 | call_method without fn_name. |
OdooTimeoutError | -32003 | Upstream Odoo timed out. |
OdooConnectError | -32004 | Proxy couldn't reach Odoo. |
InternalProxyError | -32005 | Internal proxy error. |
LicenseError | 0 (HTTP 403) | Proxy integrity/license check failed. |
OdooLogicError | Odoo's code (HTTP 200) | Odoo business error (validation, access rights). |
TransportError | — | Local transport failure (DNS/TCP/TLS/local timeout) before the proxy replies. |
Json2UnavailableError | -32006 | v2: no JSON-2 on that Odoo (18 or older), or the database isn't selectable on that host. |
InvalidRequestError | -32007 | v2: invalid model/method name, or db/api_key not valid in an HTTP header. |
Odoo-side errors that carry Odoo's HTTP status as the code — always on v2 — raise
subclasses of OdooLogicError, so except OdooLogicError still catches them
all. .odoo_error_name gives Odoo's exception class (e.g.
"odoo.exceptions.ValidationError").
| Exception | Code (on HTTP 200) | Meaning |
|---|---|---|
OdooAuthError | 401 | Odoo rejected the Odoo API key (expired, revoked, wrong scope, or a password). Not AuthError, which is the proxy key. |
OdooAccessError | 403 | Access rights, or a private method. |
OdooNotFoundError | 404 | Unknown model/method, or a missing record. |
OdooConflictError | 409 | Odoo lock conflict — the one worth retrying. |
OdooValidationError | 422 | Validation error, or bad arguments. |
OdooServerError | 5xx | Odoo server error. |
from terrakernel.odxproxyclient import AuthError, OdooLogicError, OdooTimeoutError, ODXProxyError
try:
partners = erp.search_read("res.partner", params=[[]], keyword={"context": {"tz": "UTC"}})
except AuthError:
reauth() # bad x-api-key
except OdooLogicError as e:
show(e.message) # Odoo validation / access error (arrived on HTTP 200)
except OdooTimeoutError:
retry_with_backoff()
except ODXProxyError as e:
log(e.code, e.message, e.http_status)A JSON-RPC error can arrive with HTTP 200 (Odoo logic errors surface as
OdooLogicError); the client inspects the envelope's error field for you and
raises. Any error code outside the proxy's own catalog is treated as an Odoo
pass-through (OdooLogicError). Code 0 is a LicenseError only on HTTP 403:
Odoo 19+ also uses code 0 for its own v1 errors, and those raise
OdooLogicError (fixed in 0.9.0).
Looking for another language? See the SDK overview — every client mirrors this same wire protocol.